Compare commits

9 Commits

Author SHA1 Message Date
14f9f1fce7 feat: 1、Vo包装识别增强 2、去除 位置&类型 的色块
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 15:45:35 +08:00
4dd4944107 feat: md渲染优化 2026-07-15 14:40:00 +08:00
a03e1b4819 feat: int/float/decimal精度优化
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 14:03:24 +08:00
27dfeb8a4c feat: 修改字段类型修改不生效的问题
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 13:50:14 +08:00
fd5aab5e7f feat: 坐标重命名
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 11:33:24 +08:00
8ef5abc262 feat: MQ文档补充 2026-07-15 11:29:12 +08:00
89a48a98aa feat: w06 类型推断增强辅助
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 18:05:08 +08:00
c780b269d6 feat: 文档更新 2026-07-14 17:47:11 +08:00
d4590fbb86 feat: 文档更新 2026-07-14 17:44:24 +08:00
28 changed files with 1907 additions and 249 deletions

View File

@@ -1,10 +1,10 @@
# ============================================================ # ============================================================
# 缓存序列化结构变更检测 — 业务仓库配置 # 序列化结构变更检测 — 业务仓库配置
# ============================================================ # ============================================================
# 说明: # 说明:
# - 本配置文件为业务覆盖配置,会与 jar 内 default-config.yaml 深度合并 # - 本配置文件为业务覆盖配置,会与 jar 内 default-config.yaml 深度合并
# - 未声明的项沿用工具内置默认值(忽略规则、检测模式等) # - 未声明的项沿用工具内置默认值(忽略规则、检测模式等)
# - 当前实现以 Redis 写入检测为主,后续可扩展其他缓存 # - 当前以 Redis 缓存写入检测为主MQRocketMQ/Kafka方案见 docs/MQ序列化结构检测方案.md
# 总开关 true-执行检测 false-跳过检测(流水线直接通过,不发通知) # 总开关 true-执行检测 false-跳过检测(流水线直接通过,不发通知)
enabled: true enabled: true
@@ -17,7 +17,7 @@ notify:
enabled: true enabled: true
webhook_url: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=fa14f0b3-e01a-40f6-96bd-e18beb94e85e webhook_url: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=fa14f0b3-e01a-40f6-96bd-e18beb94e85e
notify_on_clean: false notify_on_clean: false
title_prefix: "【缓存结构变更】" title_prefix: "【序列化结构变更】"
# 观察期:先只扫描 jnpf-tenant 模块,稳定后改为 include_modules: [] # 观察期:先只扫描 jnpf-tenant 模块,稳定后改为 include_modules: []
include_modules: include_modules:

View File

@@ -1,17 +1,17 @@
name: 缓存序列化结构检查 name: 序列化结构检查
run-name: ${{ gitea.actor }}的缓存结构检查 run-name: ${{ gitea.actor }}的序列化结构检查
on: on:
push: push:
workflow_dispatch: workflow_dispatch:
env: env:
# cache-schema-checker 私库坐标com.codechecker:cache-schema-checker:1.0.0 # serialization-schema-checker 私库坐标com.codechecker:serialization-schema-checker:1.0.0
CACHE_SCHEMA_CHECKER_VERSION: "1.0.0" SERIALIZATION_SCHEMA_CHECKER_VERSION: "1.0.0"
CACHE_SCHEMA_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases" SERIALIZATION_SCHEMA_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases"
jobs: jobs:
cache-schema-check: serialization-schema-check:
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }} if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
runs-on: jdk11 runs-on: jdk11
steps: steps:
@@ -27,30 +27,30 @@ jobs:
git clone --depth 1 --single-branch --branch "${BRANCH}" "${REPO_URL}" . git clone --depth 1 --single-branch --branch "${BRANCH}" "${REPO_URL}" .
git checkout -B "${BRANCH}" "${NEW_SHA}" git checkout -B "${BRANCH}" "${NEW_SHA}"
echo "${NEW_SHA}" > /tmp/cache-schema-new-sha.txt echo "${NEW_SHA}" > /tmp/serialization-schema-new-sha.txt
- name: 检查配置文件 - name: 检查配置文件
run: | run: |
if [ ! -f .gitea/config/cache-schema-check-config.yaml ]; then if [ ! -f .gitea/config/serialization-schema-check-config.yaml ]; then
echo "错误: 缺少 .gitea/config/cache-schema-check-config.yaml" echo "错误: 缺少 .gitea/config/serialization-schema-check-config.yaml"
exit 1 exit 1
fi fi
# 顶层总开关 enabled: false 时跳过后续步骤(与 notify.enabled 区分,仅匹配行首) # 顶层总开关 enabled: false 时跳过后续步骤(与 notify.enabled 区分,仅匹配行首)
if grep -Eq '^enabled:[[:space:]]*false([[:space:]]|#|$)' .gitea/config/cache-schema-check-config.yaml; then if grep -Eq '^enabled:[[:space:]]*false([[:space:]]|#|$)' .gitea/config/serialization-schema-check-config.yaml; then
echo "总开关 enabled=false跳过缓存结构检查" echo "总开关 enabled=false跳过序列化结构检查"
touch /tmp/cache-schema-check.skip touch /tmp/serialization-schema-check.skip
fi fi
- name: 从 Nexus 私库下载 cache-schema-checker - name: 从 Nexus 私库下载 serialization-schema-checker
run: | run: |
if [ -f /tmp/cache-schema-check.skip ]; then if [ -f /tmp/serialization-schema-check.skip ]; then
echo "总开关已关闭,跳过下载" echo "总开关已关闭,跳过下载"
exit 0 exit 0
fi fi
GROUP_PATH="com/codechecker/cache-schema-checker" GROUP_PATH="com/codechecker/serialization-schema-checker"
JAR_NAME="cache-schema-checker-${CACHE_SCHEMA_CHECKER_VERSION}.jar" JAR_NAME="serialization-schema-checker-${SERIALIZATION_SCHEMA_CHECKER_VERSION}.jar"
JAR_URL="${CACHE_SCHEMA_CHECKER_REPO_URL}/${GROUP_PATH}/${CACHE_SCHEMA_CHECKER_VERSION}/${JAR_NAME}" JAR_URL="${SERIALIZATION_SCHEMA_CHECKER_REPO_URL}/${GROUP_PATH}/${SERIALIZATION_SCHEMA_CHECKER_VERSION}/${JAR_NAME}"
JAR_PATH="/tmp/${JAR_NAME}" JAR_PATH="/tmp/${JAR_NAME}"
echo "下载: ${JAR_URL}" echo "下载: ${JAR_URL}"
@@ -72,25 +72,25 @@ jobs:
- name: 验证 JDK - name: 验证 JDK
run: | run: |
if [ -f /tmp/cache-schema-check.skip ]; then if [ -f /tmp/serialization-schema-check.skip ]; then
echo "总开关已关闭,跳过" echo "总开关已关闭,跳过"
exit 0 exit 0
fi fi
echo "Java: $(java -version 2>&1 | head -1)" echo "Java: $(java -version 2>&1 | head -1)"
- name: 执行缓存序列化结构检测 - name: 执行序列化结构检测
env: env:
# push 前 tip新分支首次 push 时为全 0。workflow_dispatch 可能为空,下方会回退。 # push 前 tip新分支首次 push 时为全 0。workflow_dispatch 可能为空,下方会回退。
PUSH_BEFORE: ${{ gitea.event.before }} PUSH_BEFORE: ${{ gitea.event.before }}
# push 事件携带的 commits 列表JSON用于准确统计 commit 数(不受浅克隆影响) # push 事件携带的 commits 列表JSON用于准确统计 commit 数(不受浅克隆影响)
PUSH_COMMITS_JSON: ${{ toJson(gitea.event.commits) }} PUSH_COMMITS_JSON: ${{ toJson(gitea.event.commits) }}
run: | run: |
if [ -f /tmp/cache-schema-check.skip ]; then if [ -f /tmp/serialization-schema-check.skip ]; then
echo "总开关已关闭,跳过检测" echo "总开关已关闭,跳过检测"
exit 0 exit 0
fi fi
NEW_SHA=$(cat /tmp/cache-schema-new-sha.txt) NEW_SHA=$(cat /tmp/serialization-schema-new-sha.txt)
OLD_SHA="${PUSH_BEFORE}" OLD_SHA="${PUSH_BEFORE}"
# 新分支首次 pushbefore 全 0→ 跳过 # 新分支首次 pushbefore 全 0→ 跳过
@@ -199,8 +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}")
java -jar "/tmp/cache-schema-checker-${CACHE_SCHEMA_CHECKER_VERSION}.jar" \ java -jar "/tmp/serialization-schema-checker-${SERIALIZATION_SCHEMA_CHECKER_VERSION}.jar" \
--config .gitea/config/cache-schema-check-config.yaml \ --config .gitea/config/serialization-schema-check-config.yaml \
--repo-root . \ --repo-root . \
--old-sha "$OLD_SHA" \ --old-sha "$OLD_SHA" \
--new-sha "$NEW_SHA" \ --new-sha "$NEW_SHA" \

6
.idea/compiler.xml generated
View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<annotationProcessing> <annotationProcessing>
@@ -6,11 +6,11 @@
<sourceOutputDir name="target/generated-sources/annotations" /> <sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" /> <sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" /> <outputRelativeToContentRoot value="true" />
<module name="cache-schema-checker" /> <module name="serialization-schema-checker" />
</profile> </profile>
</annotationProcessing> </annotationProcessing>
<bytecodeTargetLevel> <bytecodeTargetLevel>
<module name="cache-schema-checker" target="11" /> <module name="serialization-schema-checker" target="11" />
</bytecodeTargetLevel> </bytecodeTargetLevel>
</component> </component>
</project> </project>

View File

@@ -1,4 +1,4 @@
# 缓存序列化结构检测 — CI 集成说明 # 序列化结构检测 — CI 集成说明
--- ---
@@ -11,7 +11,7 @@ Gitea Actions 触发
浅克隆业务仓库 tipdepth=1+ 按需取 push 前 tipbefore 浅克隆业务仓库 tipdepth=1+ 按需取 push 前 tipbefore
从 Nexus 下载 cache-schema-checker.jar 从 Nexus 下载 serialization-schema-checker.jar
java -jar 执行(对比 before → after累计 diff java -jar 执行(对比 before → after累计 diff
@@ -46,7 +46,7 @@ mode=block 且含任意结构变更 → exit 1流水线失败
|----|------| |----|------|
| Gitea Runner | 标签 `jdk11`,已安装 Java 11 | | Gitea Runner | 标签 `jdk11`,已安装 Java 11 |
| Nexus 私库 | 可访问 `http://192.168.3.25:18081/nexus/repository/maven-releases` | | Nexus 私库 | 可访问 `http://192.168.3.25:18081/nexus/repository/maven-releases` |
| 工具 JAR | `com.codechecker:cache-schema-checker:1.0.0` 已发布 | | 工具 JAR | `com.codechecker:serialization-schema-checker:1.0.0` 已发布 |
--- ---
@@ -58,18 +58,18 @@ mode=block 且含任意结构变更 → exit 1流水线失败
jnpf-java-cloud/ jnpf-java-cloud/
├── .gitea/ ├── .gitea/
│ ├── workflows/ │ ├── workflows/
│ │ └── cache-schema-check.yaml # 流水线 │ │ └── serialization-schema-check.yaml # 流水线
│ └── config/ │ └── config/
│ └── cache-schema-check-config.yaml # 检测配置 │ └── serialization-schema-check-config.yaml # 检测配置
``` ```
请以本仓库 `.gitea/workflows/cache-schema-check.yaml` 为模板同步到业务仓。 请以本仓库 `.gitea/workflows/serialization-schema-check.yaml` 为模板同步到业务仓。
--- ---
## 4. 流水线模板(要点) ## 4. 流水线模板(要点)
完整可运行版本见:`.gitea/workflows/cache-schema-check.yaml` 完整可运行版本见:`.gitea/workflows/serialization-schema-check.yaml`
核心逻辑摘要: 核心逻辑摘要:
@@ -84,7 +84,7 @@ git fetch --depth 1 origin "$OLD_SHA" # 优先
# 或 git fetch --deepen N # 兜底 # 或 git fetch --deepen N # 兜底
# 4) 执行 # 4) 执行
java -jar cache-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" \
... ...
@@ -98,7 +98,7 @@ java -jar cache-schema-checker-1.0.0.jar \
|--------|------|------| |--------|------|------|
| `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 | | `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 |
| `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 | | `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 |
| `cache-schema-check` | 缓存结构检测 | 新增 | | `serialization-schema-check` | 缓存结构检测 | 新增 |
--- ---
@@ -116,9 +116,9 @@ mvn clean deploy -DskipTests
发布产物: 发布产物:
```text ```text
com/codechecker/cache-schema-checker/1.0.0/ com/codechecker/serialization-schema-checker/1.0.0/
├── cache-schema-checker-1.0.0.jar # 可执行 fat-jar ├── serialization-schema-checker-1.0.0.jar # 可执行 fat-jar
└── cache-schema-checker-1.0.0.pom └── serialization-schema-checker-1.0.0.pom
``` ```
--- ---
@@ -157,8 +157,8 @@ com/codechecker/cache-schema-checker/1.0.0/
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)
java -jar /path/to/cache-schema-checker-1.0.0.jar \ java -jar /path/to/serialization-schema-checker-1.0.0.jar \
--config .gitea/config/cache-schema-check-config.yaml \ --config .gitea/config/serialization-schema-check-config.yaml \
--repo-root . \ --repo-root . \
--old-sha "$OLD_SHA" \ --old-sha "$OLD_SHA" \
--new-sha "$NEW_SHA" \ --new-sha "$NEW_SHA" \
@@ -169,3 +169,13 @@ java -jar /path/to/cache-schema-checker-1.0.0.jar \
``` ```
单 commit 自测仍可用 `--old-sha HEAD~1 --new-sha HEAD` 单 commit 自测仍可用 `--old-sha HEAD~1 --new-sha HEAD`
---
## 10. 相关文档
| 文档 | 说明 |
|------|------|
| [实施方案.md](./实施方案.md) | 缓存检测总体方案 |
| [配置说明.md](./配置说明.md) | YAML 配置项 |
| [MQ序列化结构检测方案.md](./MQ序列化结构检测方案.md) | MQ 消息体 SchemaRocketMQ + Kafka方案已落地 |

View File

@@ -0,0 +1,339 @@
# MQ 消息体序列化结构变更检测 — 方案
> 版本v0.2
> 日期2026-07-15
> 状态:**方案已落地(含 Kafka开发未启动**
> 关联:复用 `serialization-schema-checker` 的 Schema 提取、Diff、企微通知与 CI 框架
> 业务样本仓:`jnpf-java-cloud`**RocketMQ + Kafka**
---
## 1. 背景与目标
### 1.1 为什么要做
业务同时使用 **RocketMQ****Kafka** 投递业务对象:
| 中间件 | 典型写法 | 序列化要点 |
|--------|----------|------------|
| RocketMQ | `rocketMQTemplate.syncSend(topic:tag, dto)` | Spring MessageConverter多为 Jackson把对象变成消息体 |
| Kafka | `kafkaTemplate.send(topic, vo|List)` | Spring `KafkaTemplate` + value serializer多为 Json编码对象 |
| Kafka 消费 | `@KafkaListener` + `parseObject(message, Xxx.class)` | 常以 String 接收后再 Fastjson 反序列化 |
当消息 DTO / VO **删字段、改类型、加包装层**时:
- Topic / 重试队列里仍可能有**旧结构消息**
- 新消费代码反序列化失败,或字段为空导致静默逻辑错误
这与 Redis 缓存「残留旧 value」同一类问题
| | Redis | RocketMQ | Kafka |
|--|-------|----------|-------|
| 残留形态 | 未过期 key | Topic 积压 / 重试 | Topic 积压 / 消费 lag |
| 路由标识 | key 模式 | **topic:tag** | **topic**(一般无 tag动态后缀可归一 `*` |
| 典型序列化 | Fastjson 字符串或 Template 直写 | MessageConverter | Kafka JsonSerializer / 手写 JSON 字符串 |
### 1.2 目标
在 push 时静态分析 **消息体类型的序列化 Schema** 是否相对对比区间发生变更,覆盖 **RocketMQ + Kafka**,并复用现有企微通知 / notify|block 能力。
### 1.3 非目标(本方案首版)
- 不连接真实 Broker不拉取积压消息做运行时校验
- 不解析依赖 jar 内消息类型(仅本仓 `src/main/java`
- 不替代权限、幂等、消费失败重试等业务正确性检查
- 不扫仅 Admin 建 Topic 的工具类(如 `KafkaTopicUtil`,无业务 body
- RabbitMQ 等若后续出现再扩展(当前仓以 RocketMQ / Kafka 为主)
---
## 2. 业务调研结论jnpf-java-cloud
### 2.1 RocketMQ
| 写法 | 出现情况 | 策略 |
|------|----------|------|
| `rocketMQTemplate.syncSend(dest, dto)` | 高(如钱包扣费) | **纳入** |
| `asyncSend` / `syncSendOrderly` 等 | 中 | **纳入** |
| `convertAndSend` | 视封装而定 | **纳入** |
| 先 `JSON.toJSONString` 再发 String | 较低 | unwrap 后取类型 |
| 只发 `String` / `byte[]` / `MessageExt` | 有 | **默认忽略** |
样本(资金钱包):
```java
rocketMQTemplate.syncSend(CapitalMqConstants.TOPIC + ":" + tag, req); // WalletDeductReq
@RocketMQMessageListener(...)
public class WalletDeductConsumer implements RocketMQListener<WalletDeductReq> { ... }
```
### 2.2 Kafka已确认需纳入
| 写法 | 出现情况 | 策略 |
|------|----------|------|
| `kafkaTemplate.send(topic, dto)` | 中(值班食安项等) | **纳入** |
| `kafkaTemplate.send(topic, List<Xxx>)` | 中(巡店食安项列表) | **纳入**rootArray |
| `@KafkaListener` + `String` + `JSONObject.parseObject(..., Xxx.class)` | 有(数据分析中差评) | **读侧补强 MQ-R** |
| `KafkaTopicUtil` 仅创建 Topic | 有(租户) | **忽略**(无消息体) |
生产样本(巡店):
```java
List<CheckItemDetailVo> thousandsData = ...;
kafkaTemplate.send(topicBuilder.patrolStoreTopic(tenantId), thousandsData);
```
生产样本(值班):
```java
KafkaTemplate<String, Object> kafkaTemplate;
kafkaTemplate.send(topic, data); // CheckItemDetailVO
```
消费样本(数据分析):
```java
@KafkaListener(topics = "ftb-evaluate-real-notification${...}", groupId = "...")
public void handleMessage(String message) {
AddedMessageNotificationToVO vo = JSONObject.parseObject(message, AddedMessageNotificationToVO.class);
}
```
动态 Topic如按租户拼接静态推断结果形如 `patrol-store-topic:*`,与 Redis key `*` 规则一致。
### 2.3 「Key」等价物destination
| 中间件 | 聚合键形态 | 来源 |
|--------|------------|------|
| RocketMQ | `topic:tag` | 字面量、常量、`TOPIC + ":" + TAG` |
| Kafka | `topic` | 字面量、常量、`topicBuilder.xxx(tenantId)` → 前缀+`*` |
无法解析时:展示表达式 + `<font color="comment">destination 未解析)</font>`
### 2.4 读侧补强(类比 W06
| 中间件 | 补强来源 |
|--------|----------|
| RocketMQ | `RocketMQListener<T>``onMessage(T)` + `@RocketMQMessageListener` |
| Kafka | `@KafkaListener` 方法参数类型;或方法内 `parseObject/parseArray(..., Xxx.class)`(与现有 W06 共享解析能力) |
---
## 3. 方案总览
### 3.1 产品形态
并入现有 `serialization-schema-checker`
- 同一 CLI / 流水线 / Schema Diff / 企微模板
- 配置增加 `mq_patterns`(含 RocketMQ + Kafka
- 通知按 **Topic / destination** 分块;文案统一 `Topic -->`
### 3.2 与现有链路
```text
Git Diff → 变更 Java 文件
├─ RedisW01~W05 + W06 ← 已有
└─ MQRocketMQMQ01~+ KafkaMQ-K*
+ Listener / parse 补强MQ-R ← 本方案
同一套 TypeSchema / SchemaDiffer / Skeleton / WeCom
```
对比区间push **`before``after`**。
### 3.3 核心原则
1. 只关心**消息体对象 Schema**,不关心 Broker / ACL / 限流
2. **有结构变更即告警**`block` 与缓存共用
3. **静态分析**;仅本仓 `src/main/java`
4. RocketMQ 与 Kafka **同一 Diff / 通知模型**,仅投递 AST 模式不同
---
## 4. 检测模式设计
### 4.1 生产侧 — RocketMQ
| 模式 ID | 匹配表达式 | 提取 |
|---------|------------|------|
| MQ01 | `rocketMQTemplate.syncSend(dest, payload, …)` | dest、payload 类型 |
| MQ02 | `asyncSend` / `syncSendOrderly` / `sendOneWay` 等 | 同上 |
| MQ03 | `convertAndSend(dest, payload)` | 同上 |
| MQ04 | `MessageBuilder.withPayload(obj)` 再 send | payload 类型 |
| MQ05 | 先 `toJSONString`/`getObjectToString` 再 send String | unwrap 后类型 |
### 4.2 生产侧 — Kafka
| 模式 ID | 匹配表达式 | 提取 |
|---------|------------|------|
| MQ-K01 | `kafkaTemplate.send(topic, payload)` | topic、payload 类型 |
| MQ-K02 | `kafkaTemplate.send(topic, key, payload)` | 同上(忽略分区 key |
| MQ-K03 | `send(ProducerRecord)` / `ListenableFuture` 封装若可解析 | topic + value 类型 |
| MQ-K04 | 先 JSON 序列化为 String 再 `send(topic, json)` | unwrap 后类型 |
Payload 为 `List<Xxx>` / `Collection` 时标记 **rootArray**,骨架为 JSON 数组(与 Redis List 一致)。
**忽略**
- payload 为字面量、纯无结构 `String`/`byte[]`(无业务类型时)
- 仅 Topic Admin API`AdminClient.createTopics` 等)
- destination 命中 `ignore.mq_destinations`
### 4.3 消费侧辅助(不单独告警)
| 模式 ID | 匹配 | 作用 |
|---------|------|------|
| MQ-R01 | `RocketMQListener<T>` / `@RocketMQMessageListener` | 补强同 destination 生产点 |
| MQ-R02 | `@KafkaListener` + 参数类型 `T`(非 String | 补强同 topic |
| MQ-R03 | Listener 内 `parseObject`/`parseArray(..., Xxx.class)` | 补强(可复用 W06 检测器) |
开关:`detection.mq_read_hints_enabled`(默认 true
### 4.4 Schema Diff
复用现有变更类型与注解规则。
序列化方言首版按字段名Jackson / Fastjson / Kafka JsonSerializer 差异必要时用 `manual_mappings`
---
## 5. 报告与通知
### 5.1 企微块RocketMQ / Kafka 统一)
```markdown
- Topic --> `capital-topic:WALLET_DEDUCT`
> **通道**: `RocketMQ`
> **位置**: `WalletDeductProducer#send:38`
> **类型**: `WalletDeductReq`
> **value值由** “{...}”
> **变更为:** “{...}”
- Topic --> `patrol-store-food-safe:*`
> **通道**: `Kafka`
> **位置**: `PatrolServiceImpl#sendFoodSafeData:3140`
> **类型**: `List<CheckItemDetailVo>`
> **value值由** “[{...}]”
> **变更为:** “[{...}]”
```
- 删除字段橙 `warning`;新增绿 `info`
- destination 未解析时灰色提示
- 可选后缀:「请评估消费积压与兼容反序列化」
「通道」字段用于区分中间件;若模板求简,可省略通道仅靠 Topic 形态区分。
### 5.2 CI 控制台
字段明细可标注 `RocketMQ` / `Kafka`;再输出与企微一致的 Markdown。
---
## 6. 配置草案
```yaml
detection:
patterns: [W01, W02, W03, W04, W05]
read_hints_enabled: true
mq_patterns:
# RocketMQ
- MQ01
- MQ02
- MQ03
- MQ04
- MQ05
# Kafka
- MQ-K01
- MQ-K02
- MQ-K03
- MQ-K04
mq_read_hints_enabled: true
ignore:
mq_destinations:
- "*:TEST"
- "benchmark:*"
manual_mappings:
- id: wallet-deduct-mq
writer_method: "jnpf.capital.module.wallet.mq.WalletDeductProducer#send"
key_pattern: "capital-topic:WALLET_DEDUCT"
value_type: "jnpf.model.capital.dto.WalletDeductReq"
- id: patrol-kafka-food-safe
writer_method: "jnpf.service.impl.PatrolServiceImpl#sendFoodSafeData"
key_pattern: "*-patrol-store-*" # 按实际 topic 规则调整
value_type: "jnpf.model.analyses.CheckItemDetailVo"
```
---
## 7. 分阶段交付
### Phase M1 — MVPRocketMQ + Kafka 基础投递)
| 任务 | 说明 |
|------|------|
| MQ01/MQ02 | RocketMQ `syncSend` / `asyncSend` |
| MQ-K01/MQ-K02 | Kafka `send(topic, payload)` / 三参 send |
| destination 推断 | 字面量、常量、拼接Kafka 动态 topic → `*` |
| Schema Diff + 骨架通知 | 复用 ReportBuilder可选「通道」行 |
| 夹具 | `fixtures/mq/rocket-wallet/``fixtures/mq/kafka-patrol/` |
| 配置 | `mq_patterns`(含 MQ-K*)、`ignore.mq_destinations` |
**验收**
1.`WalletDeductReq` 字段 → 企微出现 RocketMQ Topic 骨架变更
2.`CheckItemDetailVo` 字段 → 企微出现 Kafka Topic 骨架变更
### Phase M2 — 增强
| 任务 | 说明 |
|------|------|
| MQ03~MQ05、MQ-K03/K04 | convertAndSend、MessageBuilder、JSON 字符串发送、ProducerRecord |
| MQ-R01~R03 | RocketMQ Listener + Kafka `@KafkaListener` / parse 补强 |
| List 根数组骨架 | 巡店 `List<CheckItemDetailVo>` 等 |
### Phase M3 — 运营
| 任务 | 说明 |
|------|------|
| 积压风险提示文案 | 统一提示评估消费 lag / 积压 |
| 更多夹具 | 值班 Kafka、中差评 Listener、IM Favorite 等 |
| 分 webhook / 标题前缀 | 缓存 vs MQ 可选拆分 |
---
## 8. 风险与限制
| 风险 | 缓解 |
|------|------|
| Kafka topic 按租户动态拼接 | 归一 `prefix:*``manual_mappings` |
| RocketMQ / Kafka 混用同一 VO | 各投递点独立告警(符合预期) |
| Listener 收 String、parse 在方法深处 | MQ-R03 + 复用 W06 AST |
| 生产/消费跨模块对不齐 | 同仓索引 + destination 对齐;失败则仅写侧 |
| Jackson / Fastjson / Kafka JsonSerializer 细节差 | 首版字段名;必要时方言或 mapping |
| 只改消费未改生产类型 | 不告警(工具职责是消息体 Schema |
---
## 9. 决策对齐
| 项 | 结论 |
|----|------|
| 中间件范围 | **RocketMQ + Kafka**本仓已确认Rabbit 暂不纳入 |
| 对比区间 | `gitea.event.before``gitea.sha` |
| 阻断 | 与现网 `mode` 共用 |
| 级别 | 不引入 P0/P1/P2 产品展示 |
| 交付 | 同一 jarpatterns 区分 Redis / MQ含 MQ-K* |
---
## 10. 下一步
1. 评审本方案RocketMQ + Kafka 模式表)
2.**Phase M1** 开发MQ01/02 + MQ-K01/K02
3. 回写 `配置说明.md` / `CI集成说明.md` 正式配置项
4. 验收 Topic 建议:`capital-topic:WALLET_DEDUCT`RocketMQ、巡店/值班 Kafka topic

View File

@@ -1,10 +1,10 @@
# 缓存序列化结构变更检测 — 实施方案 # 序列化结构变更检测 — 实施方案
> 版本v0.2 > 版本v0.3
> 日期2026-07-14 > 日期2026-07-15
> 技术栈Java 11 + Maven + JavaParser > 技术栈Java 11 + Maven + JavaParser
> 目标仓库:`redisCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库) > 目标仓库:`schemaCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库)
> 当前阶段:**Phase 1 + Phase 2 已完成**Phase 3 待做 > 当前阶段:**Phase 1 + Phase 2 已完成**Phase 3 运营待做;**MQ 扩展方案已文档落地**(见 `docs/MQ序列化结构检测方案.md`
--- ---
@@ -104,12 +104,12 @@
schemaCheck 仓库 schemaCheck 仓库
├── 开发 Java 分析工具 ├── 开发 Java 分析工具
├── mvn package 打 fat-jar ├── mvn package 打 fat-jar
├── 发布到 Nexuscom.codechecker:cache-schema-checker:{version} ├── 发布到 Nexuscom.codechecker:serialization-schema-checker:{version}
└── 提供默认配置模板 └── 提供默认配置模板
jnpf-java-cloud 仓库 jnpf-java-cloud 仓库
├── .gitea/workflows/cache-schema-check.yaml ├── .gitea/workflows/serialization-schema-check.yaml
├── .gitea/config/cache-schema-check-config.yaml ├── .gitea/config/serialization-schema-check-config.yaml
└── push 时下载 jar 并执行检测 └── push 时下载 jar 并执行检测
``` ```
@@ -119,11 +119,11 @@ jnpf-java-cloud 仓库
flowchart TB flowchart TB
subgraph Gitea["Gitea Push Pipeline"] subgraph Gitea["Gitea Push Pipeline"]
A[push 事件] --> B[浅克隆 old/new 提交] A[push 事件] --> B[浅克隆 old/new 提交]
B --> C[下载 cache-schema-checker.jar] B --> C[下载 serialization-schema-checker.jar]
C --> D[java -jar 执行检测] C --> D[java -jar 执行检测]
end end
subgraph Checker["cache-schema-checker (JDK 11)"] subgraph Checker["serialization-schema-checker (JDK 11)"]
D --> E[GitDiffScanner] D --> E[GitDiffScanner]
E --> F[RedisWritePointDetector] E --> F[RedisWritePointDetector]
F --> G[JavaSchemaExtractor] F --> G[JavaSchemaExtractor]
@@ -143,7 +143,7 @@ flowchart TB
1. **纯静态分析**:基于 Java 源码 AST + 符号解析,不启动 Spring 容器 1. **纯静态分析**:基于 Java 源码 AST + 符号解析,不启动 Spring 容器
2. **Diff 驱动**:只分析本次 push 变更涉及的文件及其关联类型 2. **Diff 驱动**:只分析本次 push 变更涉及的文件及其关联类型
3. **本仓限定**:类型解析仅在业务仓库 `src/main/java` 范围内 3. **本仓限定**:类型解析仅在业务仓库 `src/main/java` 范围内
4. **可配置**:忽略规则、严重级别、通知开关、阻断开关均可 YAML 配置 4. **可配置**:忽略规则、通知开关、阻断开关均可 YAML 配置
5. **可演进**:已覆盖 JSON 字符串写入与 Template 直写 / Hash后续可扩展读路径反向确认、报告落盘等 5. **可演进**:已覆盖 JSON 字符串写入与 Template 直写 / Hash后续可扩展读路径反向确认、报告落盘等
--- ---
@@ -163,7 +163,7 @@ flowchart TB
**不采用** Spoon / Eclipse JDT 的原因JavaParser 足够覆盖第一版需求依赖更轻CLI 启动更快。 **不采用** Spoon / Eclipse JDT 的原因JavaParser 足够覆盖第一版需求依赖更轻CLI 启动更快。
**Lombok 处理策略**第一版基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder``@SuperBuilder` 等复杂场景标记为低置信度」并降级为 P2 提示。后续可选集成 `lombok.ast` 或 delombok 预处理。 **Lombok 处理策略**:基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder``@SuperBuilder` 等复杂场景标记为低置信度提示。后续可选集成 `lombok.ast` 或 delombok 预处理。
--- ---
@@ -175,7 +175,8 @@ schemaCheck/
├── docs/ ├── docs/
│ ├── 实施方案.md │ ├── 实施方案.md
│ ├── 配置说明.md │ ├── 配置说明.md
── CI集成说明.md ── CI集成说明.md
│ └── MQ序列化结构检测方案.md # MQ 消息体 Schema 扩展(方案)
├── src/ ├── src/
│ ├── main/ │ ├── main/
│ │ ├── resources/ │ │ ├── resources/
@@ -195,8 +196,8 @@ schemaCheck/
│ ├── resources/fixtures/{tenant,lock,template}/ │ ├── resources/fixtures/{tenant,lock,template}/
│ └── java/... │ └── java/...
├── .gitea/ ├── .gitea/
│ ├── workflows/cache-schema-check.yaml │ ├── workflows/serialization-schema-check.yaml
│ └── config/cache-schema-check-config.yaml │ └── config/serialization-schema-check-config.yaml
└── target/ # 构建产物 └── target/ # 构建产物
``` ```
@@ -204,11 +205,11 @@ schemaCheck/
```xml ```xml
<groupId>com.codechecker</groupId> <groupId>com.codechecker</groupId>
<artifactId>cache-schema-checker</artifactId> <artifactId>serialization-schema-checker</artifactId>
<version>1.0.0</version> <version>1.0.0</version>
``` ```
打包为 **shaded/fat jar**,主类:`com.codechecker.cache.cli.CacheSchemaCheckerMain` 打包为 **shaded/fat jar**,主类:`com.codechecker.cache.cli.SerializationSchemaCheckerMain`
--- ---
@@ -217,8 +218,8 @@ schemaCheck/
### 6.1 CLI 参数 ### 6.1 CLI 参数
```bash ```bash
java -jar cache-schema-checker-1.0.0.jar \ java -jar serialization-schema-checker-1.0.0.jar \
--config .gitea/config/cache-schema-check-config.yaml \ --config .gitea/config/serialization-schema-check-config.yaml \
--repo-root /path/to/jnpf-java-cloud \ --repo-root /path/to/jnpf-java-cloud \
--old-sha abc123 \ --old-sha abc123 \
--new-sha def456 \ --new-sha def456 \
@@ -255,7 +256,7 @@ java -jar cache-schema-checker-1.0.0.jar \
#### Step 1加载配置 #### Step 1加载配置
读取 `cache-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。 读取 `serialization-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。
#### Step 2Git Diff 扫描 #### Step 2Git Diff 扫描
@@ -282,9 +283,8 @@ git diff --name-only {old-sha} {new-sha} -- '*.java'
| W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 | ✅ | | W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 | ✅ |
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 | ✅ | | W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 | ✅ |
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(expr), ...)` | 同上 | ✅ | | W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(expr), ...)` | 同上 | ✅ |
| W04 | `redisTemplate.opsForValue().set(key, expr, ...)` 且 expr 非字面量 | 直写对象类型 | ✅ Phase 2 | | W04 | `redisTemplate.opsForValue().set(key, expr, ...)` 且 expr 非字面量 | 直写对象类型 | ✅ |
| W05 | `redisTemplate.opsForHash().put(key, field, expr)` | Hash 写出 value 类型 | ✅ Phase 2 | | W05 | `redisTemplate.opsForHash().put(key, field, expr)` | Hash 写出 value 类型 | ✅ |
| W06 | `JSON.parseObject(cacheValue, Xxx.class)` | 辅助反向确认读取类型 | 未做 |
**忽略规则**(自动): **忽略规则**(自动):
@@ -292,6 +292,19 @@ git diff --name-only {old-sha} {new-sha} -- '*.java'
- 方法名含 `setIfAbsent``increment``delete``remove``expire` - 方法名含 `setIfAbsent``increment``delete``remove``expire`
- key 匹配 `ignore.key_patterns` 配置(锁 / token / 登录计数等) - key 匹配 `ignore.key_patterns` 配置(锁 / token / 登录计数等)
#### Step 4b读侧类型辅助W06非写入模式
W06 **不产生独立告警**,只扫描反序列化调用,用读到的 `Xxx.class` **补强**同文件(或同 key写入点的 value 类型 / 根数组标记。
| 匹配示例 | 作用 |
|----------|------|
| `JSON.parseObject(raw, Xxx.class)` | 补强对象类型 |
| `JSON.parseArray(raw, Xxx.class)` / `JsonUtil.getJsonToList` | 补强 `List<Xxx>`rootArray |
| `JsonUtil.getJsonToBean(raw, Xxx.class)` | 同上 |
| 可关联到 `redisUtil.getString(key)` / `opsForValue().get(key)` | 同时补强 key 模式 |
开关:`detection.read_hints_enabled`(默认 `true`)。优先级:`manual_mappings` > W06 补强 > 写侧 AST 推断。
#### Step 5类型推断 #### Step 5类型推断
对每个写入点的 `expr`,使用 JavaParser Symbol Solver 推断类型: 对每个写入点的 `expr`,使用 JavaParser Symbol Solver 推断类型:
@@ -320,7 +333,7 @@ redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
| `@JSONField(name = "xxx")` | 字段名映射 | ✅ | | `@JSONField(name = "xxx")` | 字段名映射 | ✅ |
| `@JsonIgnore` | 排除字段 | ✅ | | `@JsonIgnore` | 排除字段 | ✅ |
| `@JsonProperty("xxx")` | 字段名映射 | ✅ | | `@JsonProperty("xxx")` | 字段名映射 | ✅ |
| `@JsonIgnoreProperties({...})` | 类级忽略字段 | ✅ Phase 2 | | `@JsonIgnoreProperties({...})` | 类级忽略字段 | ✅ |
| `@Schema` | 忽略(不影响序列化) | ✅ | | `@Schema` | 忽略(不影响序列化) | ✅ |
#### Step 6生成 JSON Schema #### Step 6生成 JSON Schema
@@ -353,19 +366,19 @@ redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
对比同一写入点在 old/new 两个版本的 `TypeSchema`,输出 `SchemaChange` 列表。 对比同一写入点在 old/new 两个版本的 `TypeSchema`,输出 `SchemaChange` 列表。
**变更类型与严重级别** **变更类型**(有结构差异即告警;`block` 下任意变更均阻断)
| 变更类型 | 示例 | 默认级别 | | 变更类型 | 示例 |
|----------|------|----------| |----------|------|
| `FIELD_REMOVED` | 删除 `dbName` | P0 | | `FIELD_REMOVED` | 删除 `dbName` |
| `TYPE_CHANGED` | `linkList` 从数组变对象 | P0 | | `TYPE_CHANGED` | `linkList` 从数组变对象 |
| `WRAPPER_ADDED` | 顶层增加 `vo` 包装 | P0 | | `WRAPPER_ADDED` | 顶层增加 `vo` 包装 |
| `FIELD_PATH_MOVED` | `dbName``vo.dbName` | P0 | | `FIELD_PATH_MOVED` | `dbName``vo.dbName` |
| `FIELD_ADDED` | 新增 `expiresAtMs` | P1 | | `FIELD_ADDED` | 新增 `expiresAtMs` |
| `KEY_PATTERN_CHANGED` | key 常量变更 | P1 | | `KEY_PATTERN_CHANGED` | key 常量变更 |
| `WRITE_POINT_REMOVED` | 删除缓存写入 | P1 | | `WRITE_POINT_REMOVED` | 删除缓存写入 |
| `WRITE_POINT_ADDED` | 新增缓存写入 | P2 | | `WRITE_POINT_ADDED` | 新增缓存写入 |
| `LOW_CONFIDENCE` | 类型推断失败 | P2 | | `LOW_CONFIDENCE` | 类型推断失败(仍提示,置信度较低) |
#### Step 8报告与通知 #### Step 8报告与通知
@@ -377,13 +390,13 @@ redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
企微 Markdown 规则: 企微 Markdown 规则:
- 抬头不含 mode / P0~P2 汇总;正文按 Key 展示骨架 - 抬头不含 mode 汇总;正文按 Key 展示骨架
- **删除字段**:旧骨架中橙色 `<font color="warning">` - **删除字段**:旧骨架中橙色 `<font color="warning">`
- **新增字段**:新骨架中绿色 `<font color="info">` - **新增字段**:新骨架中绿色 `<font color="info">`
- key 未解析时展示源码表达式 + 灰色「key 未解析)」 - key 未解析时展示源码表达式 + 灰色「key 未解析)」
- 单条超 4096 UTF-8 字节时按 Key 拆成多条依次发送 - 单条超 4096 UTF-8 字节时按 Key 拆成多条依次发送
CI 控制台额外输出字段明细(含严重级别),再打印与企微一致的 Markdown。 CI 控制台额外输出字段级变更明细(变更类型 + 位置 + 摘要),再打印与企微一致的 Markdown。
调用企微 Webhook 发送 Markdown支持 `--dry-run` 仅本地输出)。 调用企微 Webhook 发送 Markdown支持 `--dry-run` 仅本地输出)。
@@ -430,16 +443,16 @@ manual_mappings:
| 层级 | 位置 | 职责 | | 层级 | 位置 | 职责 |
|------|------|------| |------|------|------|
| 默认配置 | 工具 jar 内 `default-config.yaml` | 检测模式、忽略规则、严重级别默认值 | | 默认配置 | 工具 jar 内 `default-config.yaml` | 检测模式、忽略规则默认值 |
| 业务覆盖 | `jnpf-java-cloud/.gitea/config/cache-schema-check-config.yaml` | mode、notify、include_modules、manual_mappings | | 业务覆盖 | `jnpf-java-cloud/.gitea/config/serialization-schema-check-config.yaml` | mode、notify、include_modules、manual_mappings |
合并规则:**业务配置覆盖默认配置**,未声明的项沿用默认值。 合并规则:**业务配置覆盖默认配置**,未声明的项沿用默认值。
CLI 调用: CLI 调用:
```bash ```bash
java -jar cache-schema-checker.jar \ java -jar serialization-schema-checker.jar \
--config .gitea/config/cache-schema-check-config.yaml \ --config .gitea/config/serialization-schema-check-config.yaml \
... ...
``` ```
@@ -469,19 +482,19 @@ notify:
详见 `docs/CI集成说明.md`。核心流程: 详见 `docs/CI集成说明.md`。核心流程:
```yaml ```yaml
# jnpf-java-cloud/.gitea/workflows/cache-schema-check.yaml要点 # jnpf-java-cloud/.gitea/workflows/serialization-schema-check.yaml要点
# 检出:浅克隆 tipdepth 1 # 检出:浅克隆 tipdepth 1
# 检测:--old-sha = gitea.event.before--new-sha = gitea.sha # 检测:--old-sha = gitea.event.before--new-sha = gitea.sha
# 按需 fetch before 提交对象,覆盖一次 push 的多 commit 累计 diff # 按需 fetch before 提交对象,覆盖一次 push 的多 commit 累计 diff
``` ```
完整模板见 `docs/CI集成说明.md` / `.gitea/workflows/cache-schema-check.yaml` 完整模板见 `docs/CI集成说明.md` / `.gitea/workflows/serialization-schema-check.yaml`
--- ---
## 10. 分阶段交付计划 ## 10. 分阶段交付计划
### Phase 1 — MVP(约 1.5 周) ### Phase 1 — MVP✅
**目标**:跑通端到端链路,覆盖租户缓存典型场景。 **目标**:跑通端到端链路,覆盖租户缓存典型场景。
@@ -491,10 +504,10 @@ notify:
| Git diff 扫描 | 变更文件列表 | ✅ | | Git diff 扫描 | 变更文件列表 | ✅ |
| W01~W03 写入点检测 | 覆盖 JSON 字符串写入 | ✅ | | W01~W03 写入点检测 | 覆盖 JSON 字符串写入 | ✅ |
| 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 | ✅ | | 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 | ✅ |
| Schema Diff P0/P1 | 字段增删、包装、路径迁移 | ✅ | | Schema Diff | 字段增删、包装、路径迁移 | ✅ |
| 企微通知 | 按 Key 骨架 Markdown | ✅ | | 企微通知 | 按 Key 骨架 Markdown | ✅ |
| notify/block / enabled | 配置驱动 | ✅ | | notify/block / enabled | 配置驱动 | ✅ |
| 夹具测试 | TenantVO/CacheEnvelope 样本 | ✅ | | 样本夹具测试 | TenantVO/CacheEnvelope | ✅ |
**验收标准** **验收标准**
@@ -502,7 +515,7 @@ notify:
- 流水线 push 后能收到企微通知 - 流水线 push 后能收到企微通知
- `mode=block` 时任意结构变更导致 exit 1 - `mode=block` 时任意结构变更导致 exit 1
### Phase 2 — 增强(约 1 周) ### Phase 2 — 增强✅
| 任务 | 说明 | 状态 | | 任务 | 说明 | 状态 |
|------|------|------| |------|------|------|
@@ -512,8 +525,9 @@ notify:
| 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent | ✅ | | 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent | ✅ |
| 多模块性能 | 并行读文件、索引批量装载、`manual_mappings` | ✅ | | 多模块性能 | 并行读文件、索引批量装载、`manual_mappings` | ✅ |
| 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ | | 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ |
| W06 读侧辅助 | `parseObject`/`parseArray` 等补强写入 value 类型 | ✅ |
### Phase 3 — 运营(约 0.5 周) ### Phase 3 — 运营
| 任务 | 说明 | | 任务 | 说明 |
|------|------| |------|------|
@@ -521,6 +535,23 @@ notify:
| 误报反馈 | `suppressions` 按写入点 / change_types 精细忽略 | | 误报反馈 | `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. 测试策略
@@ -532,12 +563,14 @@ notify:
- `RedisWritePointDetectorTest`:各种写入 AST 模式匹配 - `RedisWritePointDetectorTest`:各种写入 AST 模式匹配
- `RedisKeyResolverTest`常量、format、拼接推断 - `RedisKeyResolverTest`常量、format、拼接推断
### 11.2 夹具集成测试 ### 11.2 样本夹具测试fixtures
`src/test/resources/fixtures/` 放置真实业务代码片段(从 `jnpf-java-cloud` 提取并脱敏),模拟 old/new 两个版本: 「夹具」= 放在测试资源里的**脱敏源码样本**(不是连真实 Redis / 不是起 Gitea 流水线)。
| 夹具 | 验证点 | 路径:`src/test/resources/fixtures/`。测试代码加载这些 `.txt`/Java 片段,在内存中跑检测器 / Schema 对比,用来验证典型业务场景是否被正确识别。
|------|--------|
| 夹具目录 | 验证点 |
|----------|--------|
| `fixtures/tenant/` | 包装结构变更TenantVO → CacheEnvelope | | `fixtures/tenant/` | 包装结构变更TenantVO → CacheEnvelope |
| `fixtures/lock/` | 锁/计数器/token 应被忽略 | | `fixtures/lock/` | 锁/计数器/token 应被忽略 |
| `fixtures/template/` | W04 Template 直写 | | `fixtures/template/` | W04 Template 直写 |
@@ -567,12 +600,20 @@ notify:
| # | 决策项 | 结论 | | # | 决策项 | 结论 |
|---|--------|------| |---|--------|------|
| 1 | 阻断范围 | `block` 模式下 **任意结构变更均阻断**exit 1 | | 1 | 阻断范围 | `block` 模式下 **任意结构变更均阻断**exit 1 |
| 2 | 发布坐标 | 独立产物 `com.codechecker:cache-schema-checker:1.0.0` | | 2 | 发布坐标 | 独立产物 `com.codechecker:serialization-schema-checker:1.0.0` |
| 3 | 配置归属 | **双层配置**jar 内 `default-config.yaml` + 业务仓覆盖合并 | | 3 | 配置归属 | **双层配置**jar 内 `default-config.yaml` + 业务仓覆盖合并 |
| 4 | 上线策略 | 先 `notify` 观察 **1 周**,稳定后手动切 `block` | | 4 | 上线策略 | 先 `notify` ,稳定后手动切 `block` |
| 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 | | 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 |
以上决策已纳入实施方案;**Phase 1 / Phase 2 已交付**可进入 Phase 3 或业务仓全量观察 以上决策已纳入实施方案;**Phase 1 / Phase 2 已交付**。缓存侧可进入 Phase 3MQ 侧以 [`MQ序列化结构检测方案.md`](./MQ序列化结构检测方案.md) 为准评审后开发
相关文档:
| 文档 | 内容 |
|------|------|
| `docs/配置说明.md` | 缓存检测双层配置 |
| `docs/CI集成说明.md` | 流水线 before/after、排障 |
| `docs/MQ序列化结构检测方案.md` | MQ 消息体 Schema 监控方案(扩展) |
--- ---
@@ -598,7 +639,6 @@ public class WritePoint {
```java ```java
public class SchemaChange { public class SchemaChange {
Severity severity; // P0, P1, P2
ChangeType changeType; // FIELD_REMOVED, WRAPPER_ADDED, ... ChangeType changeType; // FIELD_REMOVED, WRAPPER_ADDED, ...
String keyPattern; String keyPattern;
String writeLocation; // class#method:line String writeLocation; // class#method:line

View File

@@ -1,8 +1,8 @@
# 缓存序列化结构检测 — 配置说明 # 序列化结构检测 — 配置说明
> **双层配置**:工具 jar 内置 `default-config.yaml`(默认) + 业务仓库 `.gitea/config/cache-schema-check-config.yaml`(覆盖) > **双层配置**:工具 jar 内置 `default-config.yaml`(默认) + 业务仓库 `.gitea/config/serialization-schema-check-config.yaml`(覆盖)
> 工具坐标:`com.codechecker:cache-schema-checker:1.0.0` > 工具坐标:`com.codechecker:serialization-schema-checker:1.0.0`
> 主类:`com.codechecker.cache.cli.CacheSchemaCheckerMain` > 主类:`com.codechecker.cache.cli.SerializationSchemaCheckerMain`
--- ---
@@ -11,7 +11,7 @@
```text ```text
jar 内 default-config.yaml工具仓维护 jar 内 default-config.yaml工具仓维护
↓ 深度合并 ↓ 深度合并
业务仓 cache-schema-check-config.yaml业务仓维护 业务仓 serialization-schema-check-config.yaml业务仓维护
最终生效配置 最终生效配置
``` ```
@@ -23,7 +23,7 @@ jar 内 default-config.yaml工具仓维护
### 1.1 业务仓最小配置示例 ### 1.1 业务仓最小配置示例
```yaml ```yaml
# jnpf-java-cloud/.gitea/config/cache-schema-check-config.yaml # jnpf-java-cloud/.gitea/config/serialization-schema-check-config.yaml
enabled: true enabled: true
mode: notify mode: notify
@@ -43,6 +43,7 @@ include_modules:
`redisCheck` 仓库维护,随 jar 发布,默认包含: `redisCheck` 仓库维护,随 jar 发布,默认包含:
- `detection.patterns`**W01~W05**JSON 字符串写入 + Template 直写 + Hash - `detection.patterns`**W01~W05**JSON 字符串写入 + Template 直写 + Hash
- `detection.read_hints_enabled`W06 读侧反序列化类型辅助(默认 true
- `ignore.key_patterns`(锁 / 计数器 / token - `ignore.key_patterns`(锁 / 计数器 / token
- `detection.min_confidence``max_field_depth` - `detection.min_confidence``max_field_depth`
- `mode: notify``enabled: true` - `mode: notify``enabled: true`
@@ -77,7 +78,7 @@ notify:
# 无变更时是否也发通知(一般 false # 无变更时是否也发通知(一般 false
notify_on_clean: false notify_on_clean: false
# 消息标题前缀 # 消息标题前缀
title_prefix: "[缓存结构变更]" title_prefix: "[序列化结构变更]"
# 忽略规则 # 忽略规则
ignore: ignore:
@@ -106,17 +107,15 @@ detection:
- W04 # redisTemplate 直写对象 - W04 # redisTemplate 直写对象
- W05 # opsForHash().put - W05 # opsForHash().put
# 类型推断最低置信度,低于此值仅输出 P2 提示 # W06读侧反序列化类型辅助不产生独立告警
read_hints_enabled: true
# 类型推断最低置信度,低于此值标记为低置信度提示
min_confidence: 0.6 min_confidence: 0.6
# 字段展开最大深度(防止循环引用死循环) # 字段展开最大深度(防止循环引用死循环)
max_field_depth: 8 max_field_depth: 8
# 严重级别覆盖(可选)
severity_overrides:
FIELD_ADDED: P1
WRITE_POINT_ADDED: P2
# 人工补充映射(自动推断失败或需精确指定时使用) # 人工补充映射(自动推断失败或需精确指定时使用)
manual_mappings: manual_mappings:
- id: tenant-db-content - id: tenant-db-content
@@ -183,7 +182,7 @@ mode: block
| `webhook_url` | string | `""` | 企微机器人 Webhook 完整 URL优先 | | `webhook_url` | string | `""` | 企微机器人 Webhook 完整 URL优先 |
| `webhook_env` | string | — | 兼容旧字段;值为 `http` 开头时当作 URL | | `webhook_env` | string | — | 兼容旧字段;值为 `http` 开头时当作 URL |
| `notify_on_clean` | boolean | false | 无变更时是否通知 | | `notify_on_clean` | boolean | false | 无变更时是否通知 |
| `title_prefix` | string | [缓存结构变更] | 消息标题前缀 | | `title_prefix` | string | [序列化结构变更] | 消息标题前缀 |
### 3.4 ignore.key_patterns ### 3.4 ignore.key_patterns
@@ -216,6 +215,23 @@ detection:
patterns: [W01, W02, W03] patterns: [W01, W02, W03]
``` ```
### 3.5.1 detection.read_hints_enabledW06 辅助)
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `read_hints_enabled` | boolean | true | 扫描 `parseObject` / `parseArray` / `getJsonToBean` 等,补强同文件或同 key 写入点的 value 类型 |
- **不是写入模式**:不会单独因为「多了一处 parse」而告警
- 能关联到 `redis get(key)` 时,还可补强 unresolved key
- 覆盖优先级:`manual_mappings` > W06 > 写侧 AST
关闭示例:
```yaml
detection:
read_hints_enabled: false
```
### 3.6 manual_mappings ### 3.6 manual_mappings
当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)。 当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)。
@@ -266,10 +282,10 @@ suppressions:
### 5.1 结构说明 ### 5.1 结构说明
- 抬头:仓库、分支、提交、提交人、时间(**不再**展示 mode / P0P1P2 汇总 - 抬头:仓库、分支、提交、提交人、时间(展示 mode
- 正文:按 **一个 Redis Key 一块**,展示位置、类型与前后序列化骨架 - 正文:按 **一个 Redis Key 一块**,展示位置、类型与前后序列化骨架
- 超长UTF-8 > 4096 字节)时按 key **拆成多条**消息依次发送 - 超长UTF-8 > 4096 字节)时按 key **拆成多条**消息依次发送
- CI 控制台另打「字段明细」(含 P0/P1/P2企微侧不分级别 - CI 控制台另打「字段明细」(变更类型 / 位置 / 摘要),企微侧按骨架展示
### 5.2 字段高亮颜色 ### 5.2 字段高亮颜色
@@ -283,7 +299,7 @@ suppressions:
### 5.3 示例(已解析 key ### 5.3 示例(已解析 key
```markdown ```markdown
## [缓存结构变更] jnpf-java-cloud ## [序列化结构变更] jnpf-java-cloud
> **分支**: code/redis_change_detection_v1.0 > **分支**: code/redis_change_detection_v1.0
> **提交**: cedd161c → 67c8a6eb > **提交**: cedd161c → 67c8a6eb
@@ -315,8 +331,6 @@ suppressions:
## 6. 推荐上线配置 ## 6. 推荐上线配置
### 6.1 观察期(第 1 周)
业务仓默认配置: 业务仓默认配置:
```yaml ```yaml
@@ -327,23 +341,15 @@ notify:
enabled: true enabled: true
webhook_url: "" # 由流水线写入真实 Webhook webhook_url: "" # 由流水线写入真实 Webhook
include_modules: include_modules: [] # 全仓
- jnpf-tenant
``` ```
观察满 1 周、确认误报可接受后,手动切换: ---
```yaml ## 7. 扩展MQ 消息体检测(方案阶段)
mode: block
include_modules: [] # 扩至全仓
```
### 6.2 全量启用(观察期结束后) MQ**RocketMQ + Kafka**)消息体 Schema 变更监控方案已单独成文,**实现前不要求业务仓增配**。
```yaml 详见:[MQ序列化结构检测方案.md](./MQ序列化结构检测方案.md)
enabled: true
mode: block 届时预计新增:`detection.mq_patterns`(含 `MQ01~``MQ-K01~`)、`detection.mq_read_hints_enabled``ignore.mq_destinations`
include_modules: [] # 空表示全部模块
detection:
patterns: [W01, W02, W03, W04, W05]
```

10
pom.xml
View File

@@ -5,12 +5,12 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>com.codechecker</groupId> <groupId>com.codechecker</groupId>
<artifactId>cache-schema-checker</artifactId> <artifactId>serialization-schema-checker</artifactId>
<version>1.0.0</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>cache-schema-checker</name> <name>serialization-schema-checker</name>
<description>基于 JavaParser 的缓存 value 序列化结构变更检测器</description> <description>基于 JavaParser 的缓存/MQ value 序列化结构变更检测器</description>
<properties> <properties>
<maven.compiler.source>11</maven.compiler.source> <maven.compiler.source>11</maven.compiler.source>
@@ -74,7 +74,7 @@
</dependencies> </dependencies>
<build> <build>
<finalName>cache-schema-checker-${project.version}</finalName> <finalName>serialization-schema-checker-${project.version}</finalName>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
@@ -101,7 +101,7 @@
<transformers> <transformers>
<transformer <transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.codechecker.cache.cli.CacheSchemaCheckerMain</mainClass> <mainClass>com.codechecker.cache.cli.SerializationSchemaCheckerMain</mainClass>
</transformer> </transformer>
<transformer <transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/> implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>

View File

@@ -1,6 +1,8 @@
package com.codechecker.cache.analyze; 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.CacheReadHintDetector;
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;
@@ -111,6 +113,10 @@ public class SchemaCheckAnalyzer {
List<WritePoint> newWps = detectorNew.detect(path, newContent); List<WritePoint> newWps = detectorNew.detect(path, newContent);
List<WritePoint> oldWps = oldContent == null List<WritePoint> oldWps = oldContent == null
? new ArrayList<>() : detectorOld.detect(path, oldContent); ? new ArrayList<>() : detectorOld.detect(path, oldContent);
applyReadHints(newWps, path, newContent, newIndex);
if (oldContent != null) {
applyReadHints(oldWps, path, oldContent, oldIndex);
}
newWps.forEach(this::applyManualMappings); newWps.forEach(this::applyManualMappings);
oldWps.forEach(this::applyManualMappings); oldWps.forEach(this::applyManualMappings);
@@ -268,10 +274,12 @@ public class SchemaCheckAnalyzer {
paths.add(c.getOldValue()); paths.add(c.getOldValue());
} }
if (c.getChangeType() == ChangeType.WRAPPER_ADDED if (c.getChangeType() == ChangeType.WRAPPER_ADDED
&& c.getFieldPath() != null) { || c.getChangeType() == ChangeType.WRAPPER_REMOVED) {
if (c.getFieldPath() != null) {
paths.add(c.getFieldPath()); paths.add(c.getFieldPath());
} }
} }
}
return paths; return paths;
} }
@@ -425,6 +433,92 @@ public class SchemaCheckAnalyzer {
} }
} }
/**
* W06用同文件读侧反序列化类型提示补强低置信度 / 缺类型的写入点。
* manual_mappings 仍在其后执行,可覆盖本补强结果。
*/
private void applyReadHints(List<WritePoint> writePoints, String path, String content,
SourceIndex index) {
if (!config.getDetection().isReadHintsEnabled()
|| writePoints == null || writePoints.isEmpty()) {
return;
}
List<CacheReadHint> hints = new CacheReadHintDetector(index).detect(path, content);
if (hints.isEmpty()) {
return;
}
for (WritePoint wp : writePoints) {
enrichWritePointFromHints(wp, hints);
}
}
private void enrichWritePointFromHints(WritePoint wp, List<CacheReadHint> hints) {
boolean needType = wp.getResolvedValueType() == null || wp.getResolvedValueType().isEmpty()
|| wp.getConfidence() < config.getDetection().getMinConfidence();
boolean needKey = isUnresolvedKey(wp.getResolvedKeyPattern());
if (!needType && !needKey) {
return;
}
CacheReadHint best = null;
int bestScore = -1;
for (CacheReadHint hint : hints) {
int score = scoreHint(wp, hint);
if (score > bestScore) {
bestScore = score;
best = hint;
}
}
if (best == null || bestScore <= 0) {
return;
}
if (needType && best.getResolvedValueType() != null) {
wp.setResolvedValueType(best.getResolvedValueType());
wp.setRootArray(best.isRootArray());
wp.setConfidence(Math.max(wp.getConfidence(), best.getConfidence()));
}
if (needKey && best.getResolvedKeyPattern() != null
&& !isUnresolvedKey(best.getResolvedKeyPattern())) {
wp.setResolvedKeyPattern(best.getResolvedKeyPattern());
if (best.getKeyExpression() != null) {
// 保留写入侧原始表达式,仅补 key 模式
}
}
}
/** 匹配得分:同 key > 同类同方法 > 同类;无交集则 0。 */
private int scoreHint(WritePoint wp, CacheReadHint hint) {
if (hint.getResolvedValueType() == null) {
return 0;
}
boolean sameClass = wp.getEnclosingClass() != null
&& wp.getEnclosingClass().equals(hint.getEnclosingClass());
if (!sameClass) {
// 跨文件仅允许 key 模式已解析且一致
if (wp.getResolvedKeyPattern() != null
&& wp.getResolvedKeyPattern().equals(hint.getResolvedKeyPattern())
&& !isUnresolvedKey(wp.getResolvedKeyPattern())) {
return 40;
}
return 0;
}
int score = 10;
if (wp.getEnclosingMethod() != null
&& wp.getEnclosingMethod().equals(hint.getEnclosingMethod())) {
score += 20;
}
if (wp.getResolvedKeyPattern() != null
&& wp.getResolvedKeyPattern().equals(hint.getResolvedKeyPattern())
&& !isUnresolvedKey(wp.getResolvedKeyPattern())) {
score += 50;
} else if (hint.getResolvedKeyPattern() != null
&& !isUnresolvedKey(hint.getResolvedKeyPattern())
&& isUnresolvedKey(wp.getResolvedKeyPattern())) {
score += 30;
}
return score;
}
private void collectTypeNames(String content, Set<String> out) { private void collectTypeNames(String content, Set<String> out) {
if (content == null || content.isEmpty()) { if (content == null || content.isEmpty()) {
return; return;

View File

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

View File

@@ -41,7 +41,7 @@ public class CheckerConfig {
/** 企微机器人 Webhook 完整 URL */ /** 企微机器人 Webhook 完整 URL */
private String webhookUrl = ""; private String webhookUrl = "";
private boolean notifyOnClean = false; private boolean notifyOnClean = false;
private String titlePrefix = "[缓存结构变更]"; private String titlePrefix = "[序列化结构变更]";
public boolean isEnabled() { public boolean isEnabled() {
return enabled; return enabled;
@@ -110,6 +110,8 @@ public class CheckerConfig {
private List<String> patterns = new ArrayList<>(); private List<String> patterns = new ArrayList<>();
private double minConfidence = 0.6; private double minConfidence = 0.6;
private int maxFieldDepth = 8; private int maxFieldDepth = 8;
/** W06是否启用读侧反序列化类型辅助补强 */
private boolean readHintsEnabled = true;
public List<String> getPatterns() { public List<String> getPatterns() {
return patterns; return patterns;
@@ -134,6 +136,14 @@ public class CheckerConfig {
public void setMaxFieldDepth(int maxFieldDepth) { public void setMaxFieldDepth(int maxFieldDepth) {
this.maxFieldDepth = maxFieldDepth; this.maxFieldDepth = maxFieldDepth;
} }
public boolean isReadHintsEnabled() {
return readHintsEnabled;
}
public void setReadHintsEnabled(boolean readHintsEnabled) {
this.readHintsEnabled = readHintsEnabled;
}
} }
public static class ManualMapping { public static class ManualMapping {

View File

@@ -87,7 +87,7 @@ public final class ConfigLoader {
n.setEnabled(bool(notify, "enabled", true)); n.setEnabled(bool(notify, "enabled", true));
n.setWebhookUrl(resolveWebhookUrl(notify)); n.setWebhookUrl(resolveWebhookUrl(notify));
n.setNotifyOnClean(bool(notify, "notify_on_clean", false)); n.setNotifyOnClean(bool(notify, "notify_on_clean", false));
n.setTitlePrefix(str(notify, "title_prefix", "[缓存结构变更]")); n.setTitlePrefix(str(notify, "title_prefix", "[序列化结构变更]"));
Map<String, Object> ignore = asMap(map.get("ignore")); Map<String, Object> ignore = asMap(map.get("ignore"));
CheckerConfig.Ignore ig = config.getIgnore(); CheckerConfig.Ignore ig = config.getIgnore();
@@ -100,6 +100,7 @@ public final class ConfigLoader {
d.setPatterns(strList(detection.get("patterns"))); d.setPatterns(strList(detection.get("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));
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<>();

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ public enum ChangeType {
FIELD_REMOVED(Severity.P0, "字段删除"), FIELD_REMOVED(Severity.P0, "字段删除"),
TYPE_CHANGED(Severity.P0, "字段类型变更"), TYPE_CHANGED(Severity.P0, "字段类型变更"),
WRAPPER_ADDED(Severity.P0, "新增包装层"), WRAPPER_ADDED(Severity.P0, "新增包装层"),
WRAPPER_REMOVED(Severity.P0, "删除包装层"),
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"), FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
FIELD_ADDED(Severity.P1, "新增字段"), FIELD_ADDED(Severity.P1, "新增字段"),
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"), KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),

View File

@@ -22,40 +22,57 @@ public class SchemaDiffer {
public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) { public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) {
List<SchemaChange> changes = new ArrayList<>(); List<SchemaChange> changes = new ArrayList<>();
Map<String, JsonType> oldLeaves = leaves(oldSchema); Map<String, FieldSchema> oldAll = fieldsByPath(oldSchema);
Map<String, JsonType> newLeaves = leaves(newSchema); Map<String, FieldSchema> newAll = fieldsByPath(newSchema);
Map<String, FieldSchema> oldLeaves = leaves(oldSchema);
Map<String, FieldSchema> newLeaves = leaves(newSchema);
Set<String> typeChangedPaths = new LinkedHashSet<>();
// 类型变更:同路径下 JsonType 或 Java 类型任一变化(覆盖 BigDecimal→String 等同桶变更)
for (String path : oldAll.keySet()) {
FieldSchema oldField = oldAll.get(path);
FieldSchema newField = newAll.get(path);
if (newField == null) {
continue;
}
if (!isTypeChange(oldField, newField)) {
continue;
}
typeChangedPaths.add(path);
String oldLabel = typeLabel(oldField);
String newLabel = typeLabel(newField);
SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED);
c.setFieldPath(path);
c.setOldValue(oldLabel);
c.setNewValue(newLabel);
c.setMessage("字段 " + path + " 类型由 " + oldLabel + " 变为 " + newLabel);
changes.add(c);
}
Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet()); Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet());
removed.removeAll(newLeaves.keySet()); removed.removeAll(newLeaves.keySet());
removed.removeAll(typeChangedPaths);
Set<String> added = new LinkedHashSet<>(newLeaves.keySet()); Set<String> added = new LinkedHashSet<>(newLeaves.keySet());
added.removeAll(oldLeaves.keySet()); added.removeAll(oldLeaves.keySet());
added.removeAll(typeChangedPaths);
// 类型变更(同路径) // 路径迁移:
for (String path : oldLeaves.keySet()) { // - 下沉(加包装):旧路径是新路径后缀,如 dbName → vo.dbName
if (newLeaves.containsKey(path)) { // - 上提(拆包装):新路径是旧路径后缀,如 vo.dbName → dbName
JsonType oldType = oldLeaves.get(path);
JsonType newType = newLeaves.get(path);
if (oldType != newType) {
SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED);
c.setFieldPath(path);
c.setOldValue(oldType.name());
c.setNewValue(newType.name());
c.setMessage("字段 " + path + " 类型由 " + oldType + " 变为 " + newType);
changes.add(c);
}
}
}
// 路径迁移检测removed 的路径是某 added 路径的后缀)
List<String[]> moves = new ArrayList<>(); List<String[]> moves = new ArrayList<>();
Set<String> matchedRemoved = new LinkedHashSet<>(); Set<String> matchedRemoved = new LinkedHashSet<>();
Set<String> matchedAdded = new LinkedHashSet<>(); Set<String> matchedAdded = new LinkedHashSet<>();
for (String r : removed) { for (String r : removed) {
if (matchedRemoved.contains(r)) {
continue;
}
for (String a : added) { for (String a : added) {
if (matchedAdded.contains(a)) { if (matchedAdded.contains(a)) {
continue; continue;
} }
if (isSuffix(a, r)) { if (isSuffix(a, r) || isSuffix(r, a)) {
moves.add(new String[]{r, a}); moves.add(new String[]{r, a});
matchedRemoved.add(r); matchedRemoved.add(r);
matchedAdded.add(a); matchedAdded.add(a);
@@ -64,16 +81,16 @@ public class SchemaDiffer {
} }
} }
// 包装层检测:多个迁移共享同一新前缀 // 新增包装层:多个迁移共享同一新前缀
Map<String, Integer> prefixCount = new LinkedHashMap<>(); Map<String, Integer> newPrefixCount = new LinkedHashMap<>();
for (String[] move : moves) { for (String[] move : moves) {
String a = move[1]; String a = move[1];
if (a.contains(".")) { if (a.contains(".")) {
String prefix = a.substring(0, a.indexOf('.')); String prefix = a.substring(0, a.indexOf('.'));
prefixCount.merge(prefix, 1, Integer::sum); newPrefixCount.merge(prefix, 1, Integer::sum);
} }
} }
for (Map.Entry<String, Integer> e : prefixCount.entrySet()) { for (Map.Entry<String, Integer> e : newPrefixCount.entrySet()) {
if (e.getValue() >= 2) { if (e.getValue() >= 2) {
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED); SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
c.setFieldPath(e.getKey()); c.setFieldPath(e.getKey());
@@ -83,6 +100,25 @@ public class SchemaDiffer {
} }
} }
// 删除包装层:多个迁移共享同一旧前缀
Map<String, Integer> oldPrefixCount = new LinkedHashMap<>();
for (String[] move : moves) {
String r = move[0];
if (r.contains(".")) {
String prefix = r.substring(0, r.indexOf('.'));
oldPrefixCount.merge(prefix, 1, Integer::sum);
}
}
for (Map.Entry<String, Integer> e : oldPrefixCount.entrySet()) {
if (e.getValue() >= 2) {
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_REMOVED);
c.setFieldPath(e.getKey());
c.setOldValue(e.getKey());
c.setMessage("删除包装层 " + e.getKey() + ",其下字段被提升为外层(影响 " + e.getValue() + " 个字段)");
changes.add(c);
}
}
for (String[] move : moves) { for (String[] move : moves) {
SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED); SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
c.setFieldPath(move[1]); c.setFieldPath(move[1]);
@@ -99,7 +135,7 @@ public class SchemaDiffer {
} }
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED); SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
c.setFieldPath(r); c.setFieldPath(r);
c.setOldValue(oldLeaves.get(r).name()); c.setOldValue(typeLabel(oldLeaves.get(r)));
c.setMessage("删除字段 " + r); c.setMessage("删除字段 " + r);
changes.add(c); changes.add(c);
} }
@@ -111,7 +147,7 @@ public class SchemaDiffer {
} }
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED); SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
c.setFieldPath(a); c.setFieldPath(a);
c.setNewValue(newLeaves.get(a).name()); c.setNewValue(typeLabel(newLeaves.get(a)));
c.setMessage("新增字段 " + a); c.setMessage("新增字段 " + a);
changes.add(c); changes.add(c);
} }
@@ -119,11 +155,104 @@ public class SchemaDiffer {
return changes; return changes;
} }
private Map<String, JsonType> leaves(TypeSchema schema) { private boolean isTypeChange(FieldSchema oldField, FieldSchema newField) {
Map<String, JsonType> result = new LinkedHashMap<>(); if (oldField.getJsonType() != newField.getJsonType()) {
return true;
}
String oldJava = normalizeJavaType(oldField.getJavaType());
String newJava = normalizeJavaType(newField.getJavaType());
if (oldJava.isEmpty() || newJava.isEmpty()) {
return false;
}
return !oldJava.equals(newJava);
}
/**
* 展示用类型标签:优先 Java 简单名,并附带 JsonType便于识别同桶变更
*/
private String typeLabel(FieldSchema field) {
if (field == null) {
return "?";
}
String java = simpleJavaType(field.getJavaType());
JsonType json = field.getJsonType();
if (java.isEmpty() || "?".equals(java)) {
return json == null ? "?" : json.name();
}
if (json == null) {
return java;
}
return java + "/" + json.name();
}
/**
* 归一化后用于相等比较:去掉泛型与包名,基本类型与包装类型视为同一序列化类型。
*/
static String normalizeJavaType(String javaType) {
String simple = simpleJavaType(javaType);
if (simple.isEmpty() || "?".equals(simple)) {
return "";
}
switch (simple) {
case "int":
return "Integer";
case "long":
return "Long";
case "short":
return "Short";
case "byte":
return "Byte";
case "double":
return "Double";
case "float":
return "Float";
case "boolean":
return "Boolean";
case "char":
return "Character";
default:
return simple;
}
}
private static String simpleJavaType(String javaType) {
if (javaType == null) {
return "";
}
String s = javaType.trim();
if (s.isEmpty()) {
return "";
}
int lt = s.indexOf('<');
if (lt > 0) {
s = s.substring(0, lt).trim();
}
int dot = s.lastIndexOf('.');
if (dot >= 0 && dot < s.length() - 1) {
s = s.substring(dot + 1);
}
return s;
}
private Map<String, FieldSchema> fieldsByPath(TypeSchema schema) {
Map<String, FieldSchema> result = new LinkedHashMap<>();
if (schema == null) {
return result;
}
for (FieldSchema f : schema.getFields().values()) {
result.put(f.getPath(), f);
}
return result;
}
private Map<String, FieldSchema> leaves(TypeSchema schema) {
Map<String, FieldSchema> result = new LinkedHashMap<>();
if (schema == null) {
return result;
}
for (FieldSchema f : schema.getFields().values()) { for (FieldSchema f : schema.getFields().values()) {
if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) { if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) {
result.put(f.getPath(), f.getJsonType()); result.put(f.getPath(), f);
} }
} }
return result; return result;

View File

@@ -1,10 +1,12 @@
package com.codechecker.cache.key; package com.codechecker.cache.key;
import com.codechecker.cache.schema.SourceIndex; import com.codechecker.cache.schema.SourceIndex;
import com.github.javaparser.Position;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration; import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration; import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator; import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.AssignExpr;
import com.github.javaparser.ast.expr.BinaryExpr; import com.github.javaparser.ast.expr.BinaryExpr;
import com.github.javaparser.ast.expr.Expression; import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.FieldAccessExpr; import com.github.javaparser.ast.expr.FieldAccessExpr;
@@ -17,6 +19,8 @@ import java.util.Optional;
/** /**
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。 * 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
* <p>
* 对局部变量名:先查同名静态常量 / 同名方法 return再回溯方法内使用点之前最近一次声明或赋值。
*/ */
public class RedisKeyResolver { public class RedisKeyResolver {
@@ -52,12 +56,17 @@ public class RedisKeyResolver {
} }
if (expr instanceof NameExpr) { if (expr instanceof NameExpr) {
String name = ((NameExpr) expr).getNameAsString(); String name = ((NameExpr) expr).getNameAsString();
// 1) 静态常量 2) 同名方法 return 3) 局部变量定值(不改变既有优先级)
String constVal = lookupConstant(enclosingClass, name, context, depth); String constVal = lookupConstant(enclosingClass, name, context, depth);
if (constVal != null) { if (constVal != null) {
return constVal; return constVal;
} }
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth); String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
return methodVal != null ? methodVal : "*"; if (methodVal != null) {
return methodVal;
}
String localVal = lookupLocalBinding(expr, name, enclosingClass, context, depth);
return localVal != null ? localVal : "*";
} }
if (expr instanceof FieldAccessExpr) { if (expr instanceof FieldAccessExpr) {
FieldAccessExpr fae = (FieldAccessExpr) expr; FieldAccessExpr fae = (FieldAccessExpr) expr;
@@ -94,6 +103,76 @@ public class RedisKeyResolver {
return "*"; return "*";
} }
/**
* 在包围方法内,取使用点之前对 {@code name} 最近一次声明初始化或赋值的右侧表达式。
*/
private String lookupLocalBinding(Expression useSite, String name,
ClassOrInterfaceDeclaration enclosingClass,
SourceIndex.IndexedType context, int depth) {
if (useSite == null || name == null || name.isEmpty()) {
return null;
}
MethodDeclaration method = useSite.findAncestor(MethodDeclaration.class).orElse(null);
if (method == null || !method.getBody().isPresent()) {
return null;
}
Position usePos = useSite.getBegin().orElse(null);
Expression bestRhs = null;
Position bestPos = null;
for (VariableDeclarator var : method.findAll(VariableDeclarator.class)) {
if (!name.equals(var.getNameAsString()) || !var.getInitializer().isPresent()) {
continue;
}
Position pos = var.getBegin().orElse(null);
if (!isUsableBinding(pos, usePos, bestPos)) {
continue;
}
bestPos = pos;
bestRhs = var.getInitializer().get();
}
for (AssignExpr assign : method.findAll(AssignExpr.class)) {
if (!(assign.getTarget() instanceof NameExpr)) {
continue;
}
if (!name.equals(((NameExpr) assign.getTarget()).getNameAsString())) {
continue;
}
Position pos = assign.getBegin().orElse(null);
if (!isUsableBinding(pos, usePos, bestPos)) {
continue;
}
bestPos = pos;
bestRhs = assign.getValue();
}
if (bestRhs == null) {
return null;
}
// 避免自引用String key = key陷入无意义递归
if (bestRhs instanceof NameExpr
&& name.equals(((NameExpr) bestRhs).getNameAsString())) {
return null;
}
return resolveExpr(bestRhs, enclosingClass, context, depth + 1);
}
/** 候选须严格在使用点之前,并在多个候选中取最近一次。 */
private static boolean isUsableBinding(Position candidate, Position usePos, Position bestPos) {
if (candidate == null) {
return bestPos == null;
}
if (usePos != null && !candidate.isBefore(usePos)) {
return false;
}
if (bestPos == null) {
return true;
}
return candidate.isAfter(bestPos);
}
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name, private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name,
SourceIndex.IndexedType context, int depth) { SourceIndex.IndexedType context, int depth) {
if (clazz == null) { if (clazz == null) {

View File

@@ -1,5 +1,6 @@
package com.codechecker.cache.report; package com.codechecker.cache.report;
import com.codechecker.cache.diff.ChangeType;
import com.codechecker.cache.diff.SchemaChange; import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity; import com.codechecker.cache.diff.Severity;
@@ -14,7 +15,7 @@ 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>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li>
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li> * <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
* <li>CI先打字段明细再完整输出企微 Markdown拆分后的每条</li> * <li>CI先打字段明细再完整输出企微 Markdown拆分后的每条</li>
@@ -28,7 +29,7 @@ public class ReportBuilder {
private final String titlePrefix; private final String titlePrefix;
public ReportBuilder(String titlePrefix) { public ReportBuilder(String titlePrefix) {
this.titlePrefix = titlePrefix == null ? "[缓存结构变更]" : titlePrefix; this.titlePrefix = titlePrefix == null ? "[序列化结构变更]" : titlePrefix;
} }
/** /**
@@ -63,7 +64,7 @@ public class ReportBuilder {
String header = buildHeader(report); String header = buildHeader(report);
List<String> bodies = buildKeyBodies(report); List<String> bodies = buildKeyBodies(report);
if (bodies.isEmpty()) { if (bodies.isEmpty()) {
return Collections.singletonList(header + "未检测到缓存序列化结构变更。\n"); return Collections.singletonList(header + "未检测到序列化结构变更。\n");
} }
StringBuilder combined = new StringBuilder(header); StringBuilder combined = new StringBuilder(header);
@@ -128,25 +129,92 @@ public class ReportBuilder {
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()); Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
Set<String> newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails());
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
Set<String> wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails());
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
String oldRendered = oldJson.isEmpty() String oldRendered = oldJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateOldForWecom(oldJson, oldHighlight) + ""; ? "" : "" + SkeletonAnnotator.annotateOldForWecom(
oldJson, oldHighlight, typeGray, wrappersRemoved) + "";
String newRendered = newJson.isEmpty() String newRendered = newJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateNewForWecom(newJson, newHighlight) + ""; ? "" : "" + SkeletonAnnotator.annotateNewForWecom(
newJson, newGreen, typeGray, wrappersAdded) + "";
if (oldJson.isEmpty() && !newJson.isEmpty()) { if (oldJson.isEmpty() && !newJson.isEmpty()) {
sb.append(" > **value 新增为:** ").append(newRendered).append("\n\n"); sb.append(" > **value 新增为:** ").append(newRendered).append('\n');
} else if (!oldJson.isEmpty() && newJson.isEmpty()) { } else if (!oldJson.isEmpty() && newJson.isEmpty()) {
sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n\n"); sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n");
} else { } else {
sb.append(" > **value值由** ").append(oldRendered).append('\n'); sb.append(" > **value值由** ").append(oldRendered).append('\n');
sb.append(" > **变更为:** ").append(newRendered).append("\n\n"); sb.append(" > **变更为:** ").append(newRendered).append('\n');
} }
appendTypeChangeSummary(sb, kc.getFieldDetails());
sb.append('\n');
return sb.toString(); return sb.toString();
} }
/**
* 类型变更单独一行摘要,避免仅靠骨架颜色/占位难以识别。
* 例:{@code > **类型变更**: amount <font color="warning">BigDecimal → Integer</font>}
*/
private void appendTypeChangeSummary(StringBuilder sb, List<SchemaChange> details) {
if (details == null || details.isEmpty()) {
return;
}
List<String> parts = new ArrayList<>();
for (SchemaChange c : details) {
if (c == null || c.getChangeType() != ChangeType.TYPE_CHANGED) {
continue;
}
String path = nvl(c.getFieldPath());
if (path.isEmpty()) {
continue;
}
String oldType = displayJavaType(c.getOldValue());
String newType = displayJavaType(c.getNewValue());
StringBuilder part = new StringBuilder();
// 字段名用普通文本(避免反引号被企微渲染成色块)
part.append(path);
if (!oldType.isEmpty() || !newType.isEmpty()) {
part.append(" <font color=\"warning\">")
.append(oldType.isEmpty() ? "?" : oldType)
.append("")
.append(newType.isEmpty() ? "?" : newType)
.append("</font>");
}
parts.add(part.toString());
}
if (parts.isEmpty()) {
return;
}
sb.append(" > **类型变更**: ");
for (int i = 0; i < parts.size(); i++) {
if (i > 0) {
sb.append("");
}
sb.append(parts.get(i));
}
sb.append('\n');
}
/** 将 {@code BigDecimal/NUMBER} 转为展示用 {@code BigDecimal}。 */
private static String displayJavaType(String typeLabel) {
if (typeLabel == null) {
return "";
}
String s = typeLabel.trim();
if (s.isEmpty()) {
return "";
}
int slash = s.indexOf('/');
if (slash > 0) {
s = s.substring(0, slash).trim();
}
return s;
}
/** /**
* Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。 * Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
* 反引号仅包裹 key 文本,避免与加粗/颜色嵌套冲突 * 反引号内仍须转义 {@code *},否则企微会把 {@code *:*} 当成斜体吃掉通配符
*/ */
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression, private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
boolean unresolved) { boolean unresolved) {
@@ -156,17 +224,28 @@ public class ReportBuilder {
if (keyText.isEmpty()) { if (keyText.isEmpty()) {
keyText = "unknown-key"; keyText = "unknown-key";
} }
sb.append("- Key --> `").append(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 无法解析)</font>");
} }
sb.append('\n'); sb.append('\n');
} }
/** 位置、类型作为每个 Key 块的通用项。 */ /**
* 企微 markdown 代码片段内的转义:{@code *} 会触发斜体(即使包在反引号里)。
*/
static String escapeWeComCode(String text) {
if (text == null || text.isEmpty()) {
return "";
}
// 反斜杠转义对企微不可靠;用全角 保留通配语义且不被吃掉
return text.replace("*", "");
}
/** 位置、类型作为每个 Key 块的通用项(不加反引号,避免企微渲染成色块)。 */
private void appendMetaLines(StringBuilder sb, String writeLocation, String valueType) { private void appendMetaLines(StringBuilder sb, String writeLocation, String valueType) {
sb.append(" > **位置**: `").append(nvl(writeLocation)).append("`\n"); sb.append(" > **位置**: ").append(nvl(writeLocation)).append('\n');
sb.append(" > **类型**: `").append(nvl(valueType)).append("`\n"); sb.append(" > **类型**: ").append(nvl(valueType)).append('\n');
} }
private boolean isUnresolvedKey(String keyPattern) { private boolean isUnresolvedKey(String keyPattern) {
@@ -182,7 +261,7 @@ public class ReportBuilder {
public String toConsole(CheckReport report) { public String toConsole(CheckReport report) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
if (!report.hasChanges()) { if (!report.hasChanges()) {
sb.append("未检测到缓存序列化结构变更。\n"); sb.append("未检测到序列化结构变更。\n");
return sb.toString(); return sb.toString();
} }
@@ -202,7 +281,7 @@ public class ReportBuilder {
for (SchemaChange c : list) { for (SchemaChange c : list) {
sb.append("- **").append(c.getChangeType().getLabel()).append("**"); sb.append("- **").append(c.getChangeType().getLabel()).append("**");
if (c.getKeyPattern() != null) { if (c.getKeyPattern() != null) {
sb.append(" `").append(c.getKeyPattern()).append('`'); sb.append(" `").append(escapeWeComCode(c.getKeyPattern())).append('`');
} }
sb.append('\n'); sb.append('\n');
if (c.getWriteLocation() != null) { if (c.getWriteLocation() != null) {
@@ -250,8 +329,8 @@ public class ReportBuilder {
+ trimmed.substring(colonEn + 1).trim(); + trimmed.substring(colonEn + 1).trim();
} }
String[] knownPrefixes = { String[] knownPrefixes = {
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "新增缓存写入点,", "删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "删除包装层 ",
"删除缓存写入点,原 value 类型: " "新增缓存写入点,", "删除缓存写入点,原 value 类型: "
}; };
for (String prefix : knownPrefixes) { for (String prefix : knownPrefixes) {
if (trimmed.startsWith(prefix)) { if (trimmed.startsWith(prefix)) {

View File

@@ -12,23 +12,28 @@ import java.util.Set;
/** /**
* 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。 * 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。
* <ul> * <ul>
* <li>删除字段 → 旧骨架,橙色 {@code warning}</li> * <li>删除包装层 → 旧骨架优先整段标橙(先于子路径)</li>
* <li>新增字段 / 新增包装层骨架,绿色 {@code info}</li> * <li>删除字段 / 路径迁移旧侧骨架,色 {@code warning}</li>
* <li>路径迁移 → 旧路径橙、新路径绿</li> * <li>新增包装层 → 新骨架优先整体标绿(先于子路径</li>
* <li>新增字段 / 路径迁移新侧 → 新骨架,绿色 {@code info}</li>
* <li>类型变更(修改类)→ 新旧骨架均为灰色 {@code comment}</li>
* </ul> * </ul>
*/ */
final class SkeletonAnnotator { final class SkeletonAnnotator {
/** 企微橙:删除 / 旧侧变更 */ /** 企微橙:删除 / 路径迁移旧侧 / 删除包装层 */
static final String COLOR_REMOVE = "warning"; static final String COLOR_REMOVE = "warning";
/** 企微绿:新增 / 新侧变更 */ /** 企微绿:新增 / 新侧路径迁移 */
static final String COLOR_ADD = "info"; static final String COLOR_ADD = "info";
/** 企微灰:类型变更(修改类) */
static final String COLOR_TYPE = "comment";
private static final String FONT_CLOSE = "</font>"; private static final String FONT_CLOSE = "</font>";
private SkeletonAnnotator() { private SkeletonAnnotator() {
} }
/** 旧骨架应橙色标注的路径:删除、路径迁移旧侧(不含类型变更与包装层删除)。 */
static Set<String> pathsForOldSkeleton(List<SchemaChange> details) { static Set<String> pathsForOldSkeleton(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>(); Set<String> paths = new LinkedHashSet<>();
if (details == null) { if (details == null) {
@@ -47,6 +52,35 @@ final class SkeletonAnnotator {
return paths; return paths;
} }
/** 删除包装层路径(旧骨架优先整段标橙)。 */
static Set<String> pathsForWrapperRemoved(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>();
if (details == null) {
return paths;
}
for (SchemaChange c : details) {
if (c != null && c.getChangeType() == ChangeType.WRAPPER_REMOVED) {
addIfPresent(paths, c.getFieldPath());
}
}
return paths;
}
/** 新增包装层路径(新骨架优先整段标绿)。 */
static Set<String> pathsForWrapperAdded(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>();
if (details == null) {
return paths;
}
for (SchemaChange c : details) {
if (c != null && c.getChangeType() == ChangeType.WRAPPER_ADDED) {
addIfPresent(paths, c.getFieldPath());
}
}
return paths;
}
/** 新骨架应绿色标注的路径:新增字段、路径迁移新侧(不含包装层与类型变更)。 */
static Set<String> pathsForNewSkeleton(List<SchemaChange> details) { static Set<String> pathsForNewSkeleton(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>(); Set<String> paths = new LinkedHashSet<>();
if (details == null) { if (details == null) {
@@ -56,8 +90,7 @@ final class SkeletonAnnotator {
if (c == null || c.getChangeType() == null) { if (c == null || c.getChangeType() == null) {
continue; continue;
} }
if (c.getChangeType() == ChangeType.FIELD_ADDED if (c.getChangeType() == ChangeType.FIELD_ADDED) {
|| c.getChangeType() == ChangeType.WRAPPER_ADDED) {
addIfPresent(paths, c.getFieldPath()); addIfPresent(paths, c.getFieldPath());
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) { } else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
addIfPresent(paths, c.getFieldPath()); addIfPresent(paths, c.getFieldPath());
@@ -69,23 +102,68 @@ final class SkeletonAnnotator {
return paths; return paths;
} }
/** /** 类型变更路径:新旧骨架均用灰色标注。 */
* 标注旧骨架改动字段(删除 → 橙色)。 static Set<String> pathsForTypeChanged(List<SchemaChange> details) {
*/ Set<String> paths = new LinkedHashSet<>();
static String annotateOldForWecom(String json, Set<String> highlightPaths) { if (details == null) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE); return paths;
}
for (SchemaChange c : details) {
if (c != null && c.getChangeType() == ChangeType.TYPE_CHANGED) {
addIfPresent(paths, c.getFieldPath());
}
}
return paths;
} }
/** /**
* 标注骨架改动字段(新增 → 绿色) * 标注骨架:删除等橙色;类型变更灰色
*/ */
static String annotateNewForWecom(String json, Set<String> highlightPaths) { static String annotateOldForWecom(String json, Set<String> orangePaths) {
return annotateForWecom(json, highlightPaths, COLOR_ADD); return annotateOldForWecom(json, orangePaths, null, null);
}
static String annotateOldForWecom(String json, Set<String> orangePaths, Set<String> grayPaths) {
return annotateOldForWecom(json, orangePaths, grayPaths, null);
}
/**
* @param wrapperRemovedPaths 删除的包装层,优先于普通橙路径标注
*/
static String annotateOldForWecom(String json, Set<String> orangePaths,
Set<String> grayPaths, Set<String> wrapperRemovedPaths) {
String result = annotateForWecom(json, grayPaths, COLOR_TYPE);
result = annotateForWecom(result, wrapperRemovedPaths, COLOR_REMOVE);
return annotateForWecom(result, orangePaths, COLOR_REMOVE);
}
/**
* 标注新骨架:类型变更灰色,新增绿色。
*/
static String annotateNewForWecom(String json, Set<String> greenPaths) {
return annotateNewForWecom(json, greenPaths, null, null);
}
/**
* 标注新骨架(类型变更 → 灰色;包装层先绿;其余新增/迁移 → 绿)。
*/
static String annotateNewForWecom(String json, Set<String> greenPaths, Set<String> grayTypePaths) {
return annotateNewForWecom(json, greenPaths, grayTypePaths, null);
}
/**
* @param wrapperPaths 包装层路径,优先于 {@code greenPaths} 标注,使 {@code "vo":{...}} 能整段染色
*/
static String annotateNewForWecom(String json, Set<String> greenPaths,
Set<String> grayTypePaths, Set<String> wrapperPaths) {
String result = annotateForWecom(json, grayTypePaths, COLOR_TYPE);
result = annotateForWecom(result, wrapperPaths, COLOR_ADD);
return annotateForWecom(result, greenPaths, COLOR_ADD);
} }
/** /**
* 仅标注改动字段:其余正文保持普通文本。 * 仅标注改动字段:其余正文保持普通文本。
* 改动片段格式:{@code <font color="warning|info">"field":value</font>} * 改动片段格式:{@code <font color="warning|info|comment">"field":value</font>}
*/ */
static String annotateForWecom(String json, Set<String> highlightPaths) { static String annotateForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE); return annotateForWecom(json, highlightPaths, COLOR_REMOVE);

View File

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

View File

@@ -45,7 +45,7 @@ public class SkeletonJsonRenderer {
private Node buildTree(TypeSchema schema) { private Node buildTree(TypeSchema schema) {
Node root = new Node(JsonType.OBJECT); Node root = new Node(JsonType.OBJECT);
for (FieldSchema field : schema.getFields().values()) { for (FieldSchema field : schema.getFields().values()) {
putPath(root, field.getPath(), field.getJsonType()); putPath(root, field.getPath(), field.getJsonType(), field.getJavaType());
} }
// 根数组:字段以 [] / [].xxx 记录 // 根数组:字段以 [] / [].xxx 记录
if (root.children.size() == 1 && root.children.containsKey("[]")) { if (root.children.size() == 1 && root.children.containsKey("[]")) {
@@ -56,7 +56,7 @@ public class SkeletonJsonRenderer {
return root; return root;
} }
private void putPath(Node root, String path, JsonType type) { private void putPath(Node root, String path, JsonType type, String javaType) {
List<Seg> segs = parsePath(path); List<Seg> segs = parsePath(path);
if (segs.isEmpty()) { if (segs.isEmpty()) {
return; return;
@@ -70,10 +70,9 @@ public class SkeletonJsonRenderer {
arr.type = JsonType.ARRAY; arr.type = JsonType.ARRAY;
Node elem = arr.children.computeIfAbsent("[]", k -> new Node(JsonType.OBJECT)); Node elem = arr.children.computeIfAbsent("[]", k -> new Node(JsonType.OBJECT));
if (last) { if (last) {
if (type == JsonType.OBJECT || type == JsonType.ARRAY || type == JsonType.MAP) {
elem.type = type;
} else {
elem.type = type; elem.type = type;
elem.javaType = javaType;
if (type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP) {
elem.leaf = true; elem.leaf = true;
} }
} }
@@ -83,6 +82,7 @@ public class SkeletonJsonRenderer {
k -> new Node(last ? type : JsonType.OBJECT)); k -> new Node(last ? type : JsonType.OBJECT));
if (last) { if (last) {
child.type = type; child.type = type;
child.javaType = javaType;
child.leaf = type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP; child.leaf = type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP;
} else if (child.type != JsonType.ARRAY) { } else if (child.type != JsonType.ARRAY) {
child.type = JsonType.OBJECT; child.type = JsonType.OBJECT;
@@ -129,7 +129,7 @@ public class SkeletonJsonRenderer {
return "[" + write(elem, elemPath, protectedPaths, compact) + "]"; return "[" + write(elem, elemPath, protectedPaths, compact) + "]";
} }
if (node.leaf || isScalar(node.type)) { if (node.leaf || isScalar(node.type)) {
return placeholder(node.type); return placeholder(node.type, node.javaType);
} }
if (node.type == JsonType.MAP) { if (node.type == JsonType.MAP) {
return "{}"; return "{}";
@@ -161,7 +161,7 @@ public class SkeletonJsonRenderer {
} else if (child.type == JsonType.ARRAY) { } else if (child.type == JsonType.ARRAY) {
sb.append(write(child, childPath, protectedPaths, compact)); sb.append(write(child, childPath, protectedPaths, compact));
} else if (child.leaf || isScalar(child.type)) { } else if (child.leaf || isScalar(child.type)) {
sb.append(placeholder(child.type)); sb.append(placeholder(child.type, child.javaType));
} else { } else {
sb.append(write(child, childPath, protectedPaths, compact)); sb.append(write(child, childPath, protectedPaths, compact));
} }
@@ -177,7 +177,7 @@ public class SkeletonJsonRenderer {
if (child.type == JsonType.OBJECT || child.type == JsonType.MAP) { if (child.type == JsonType.OBJECT || child.type == JsonType.MAP) {
return "\"...\""; return "\"...\"";
} }
return placeholder(child.type); return placeholder(child.type, child.javaType);
} }
private boolean isProtectedUnder(String pathPrefix, Set<String> protectedPaths) { private boolean isProtectedUnder(String pathPrefix, Set<String> protectedPaths) {
@@ -327,13 +327,17 @@ public class SkeletonJsonRenderer {
|| type == JsonType.BOOLEAN || type == JsonType.UNKNOWN; || type == JsonType.BOOLEAN || type == JsonType.UNKNOWN;
} }
private String placeholder(JsonType type) { /**
* 数字类型示意占位(合法 JSON number整数 {@code 0}、浮点 {@code 0.0}、BigDecimal {@code 0.00}。
* 仅用于通知可读性,不代表真实精度/scale。
*/
private String placeholder(JsonType type, String javaType) {
if (type == null) { if (type == null) {
return "null"; return "null";
} }
switch (type) { switch (type) {
case NUMBER: case NUMBER:
return "0"; return numberPlaceholder(javaType);
case BOOLEAN: case BOOLEAN:
return "false"; return "false";
case STRING: case STRING:
@@ -349,6 +353,37 @@ public class SkeletonJsonRenderer {
} }
} }
private String numberPlaceholder(String javaType) {
String simple = simpleJavaType(javaType);
if ("BigDecimal".equals(simple)) {
return "0.00";
}
if ("float".equals(simple) || "Float".equals(simple)
|| "double".equals(simple) || "Double".equals(simple)) {
return "0.0";
}
return "0";
}
private static String simpleJavaType(String javaType) {
if (javaType == null) {
return "";
}
String s = javaType.trim();
if (s.isEmpty()) {
return "";
}
int lt = s.indexOf('<');
if (lt > 0) {
s = s.substring(0, lt).trim();
}
int dot = s.lastIndexOf('.');
if (dot >= 0 && dot < s.length() - 1) {
s = s.substring(dot + 1);
}
return s;
}
private String escape(String s) { private String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\""); return s.replace("\\", "\\\\").replace("\"", "\\\"");
} }
@@ -365,6 +400,7 @@ public class SkeletonJsonRenderer {
private static final class Node { private static final class Node {
JsonType type; JsonType type;
String javaType;
boolean leaf; boolean leaf;
final Map<String, Node> children = new LinkedHashMap<>(); final Map<String, Node> children = new LinkedHashMap<>();

View File

@@ -1,4 +1,4 @@
# cache-schema-checker 内置默认配置 # serialization-schema-checker 内置默认配置
# 业务仓库通过 --config 指定的配置会与本文件深度合并(业务配置优先)。 # 业务仓库通过 --config 指定的配置会与本文件深度合并(业务配置优先)。
# 总开关false 时不执行检测、不发通知、流水线直接通过 # 总开关false 时不执行检测、不发通知、流水线直接通过
@@ -19,7 +19,7 @@ notify:
enabled: true enabled: true
webhook_url: "" webhook_url: ""
notify_on_clean: false notify_on_clean: false
title_prefix: "[缓存结构变更]" title_prefix: "[序列化结构变更]"
# 忽略规则 # 忽略规则
ignore: ignore:
@@ -44,7 +44,9 @@ 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)
# 类型推断最低置信度,低于此值降级为 P2 提示 # W06 读侧辅助:用 parseObject / getJsonToBean 等补强写入点 value 类型(非写入模式)
read_hints_enabled: true
# 类型推断最低置信度,低于此值标记为低置信度提示
min_confidence: 0.6 min_confidence: 0.6
# 字段展开最大深度(防止循环引用) # 字段展开最大深度(防止循环引用)
max_field_depth: 8 max_field_depth: 8

View File

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

View File

@@ -40,6 +40,41 @@ class SchemaDifferTest {
assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移"); assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移");
} }
@Test
void detectsWrapperRemovalAndUpwardMoves() {
TypeSchema oldS = new TypeSchema("CacheEnvelope");
oldS.add(new FieldSchema("vo", JsonType.OBJECT, "TenantVO"));
oldS.add(new FieldSchema("vo.dbName", JsonType.STRING, "String"));
oldS.add(new FieldSchema("vo.linkList", JsonType.ARRAY, "List"));
oldS.add(new FieldSchema("vo.linkList[]", JsonType.OBJECT, "TenantLinkModel"));
oldS.add(new FieldSchema("vo.linkList[].id", JsonType.STRING, "String"));
TypeSchema newS = new TypeSchema("TenantVO");
newS.add(new FieldSchema("dbName", JsonType.STRING, "String"));
newS.add(new FieldSchema("linkList", JsonType.ARRAY, "List"));
newS.add(new FieldSchema("linkList[]", JsonType.OBJECT, "TenantLinkModel"));
newS.add(new FieldSchema("linkList[].id", JsonType.STRING, "String"));
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
assertTrue(types.contains(ChangeType.WRAPPER_REMOVED), "应检测到包装层删除");
assertTrue(types.contains(ChangeType.FIELD_PATH_MOVED), "应检测到字段上提迁移");
assertTrue(types.stream().noneMatch(t -> t == ChangeType.FIELD_ADDED),
"上提字段不应再被当成新增");
assertTrue(types.stream().noneMatch(t -> t == ChangeType.FIELD_REMOVED),
"上提字段不应再被当成删除");
SchemaChange wrapper = changes.stream()
.filter(c -> c.getChangeType() == ChangeType.WRAPPER_REMOVED)
.findFirst()
.orElseThrow(IllegalStateException::new);
assertEquals("vo", wrapper.getFieldPath());
long moved = changes.stream().filter(c -> c.getChangeType() == ChangeType.FIELD_PATH_MOVED).count();
assertEquals(2, moved, "vo.dbName / vo.linkList[].id 均应上提");
}
@Test @Test
void detectsTypeChange() { void detectsTypeChange() {
TypeSchema oldS = new TypeSchema("A"); TypeSchema oldS = new TypeSchema("A");
@@ -51,6 +86,57 @@ class SchemaDifferTest {
assertEquals(1, changes.size()); assertEquals(1, changes.size());
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType()); assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
assertEquals(Severity.P0, changes.get(0).getSeverity()); assertEquals(Severity.P0, changes.get(0).getSeverity());
assertTrue(changes.get(0).getMessage().contains("String"));
assertTrue(changes.get(0).getMessage().contains("Integer"));
}
@Test
void detectsBigDecimalToStringEvenWhenBothWereStringBucketHistorically() {
// 即便 JsonType 同为 STRINGjavaType 变化也必须检出(用户真实漏报场景)
TypeSchema oldS = new TypeSchema("A");
oldS.add(new FieldSchema("amount", JsonType.STRING, "BigDecimal"));
TypeSchema newS = new TypeSchema("A");
newS.add(new FieldSchema("amount", JsonType.STRING, "String"));
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
assertEquals(1, changes.size());
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
assertEquals("amount", changes.get(0).getFieldPath());
assertTrue(changes.get(0).getOldValue().contains("BigDecimal"));
assertTrue(changes.get(0).getNewValue().contains("String"));
}
@Test
void detectsBigDecimalToStringViaJsonTypeAndJavaType() {
TypeSchema oldS = new TypeSchema("A");
oldS.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
TypeSchema newS = new TypeSchema("A");
newS.add(new FieldSchema("amount", JsonType.STRING, "String"));
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
assertEquals(1, changes.size());
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
}
@Test
void detectsIntegerToLongWithinNumberBucket() {
TypeSchema oldS = new TypeSchema("A");
oldS.add(new FieldSchema("id", JsonType.NUMBER, "Integer"));
TypeSchema newS = new TypeSchema("A");
newS.add(new FieldSchema("id", JsonType.NUMBER, "Long"));
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
assertEquals(1, changes.size());
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
}
@Test
void noChangeWhenPrimitiveAndWrapperEquivalent() {
TypeSchema a = new TypeSchema("A");
a.add(new FieldSchema("n", JsonType.NUMBER, "int"));
TypeSchema b = new TypeSchema("A");
b.add(new FieldSchema("n", JsonType.NUMBER, "Integer"));
assertTrue(new SchemaDiffer().diff(a, b).isEmpty());
} }
@Test @Test

View File

@@ -65,4 +65,91 @@ class RedisKeyResolverTest {
buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper")); buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper"));
assertEquals("tenant:db:content:*", pattern); assertEquals("tenant:db:content:*", pattern);
} }
@Test
void resolvesLocalVariableAssignedFromStringFormat() {
String source = ""
+ "package demo;\n"
+ "public class CombinationRepo {\n"
+ " private static final String COMBINATION_QUERY_KEY = "
+ "\"data_analysis:combination_query_key:%s:%s\";\n"
+ " public void write(String tenantId, String hashKey) {\n"
+ " String key = String.format(COMBINATION_QUERY_KEY, tenantId, hashKey);\n"
+ " redisSet(key);\n"
+ " }\n"
+ " void redisSet(String k) {}\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
// redisSet(key) 的实参 key —— 局部变量使用点
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
.filter(m -> "redisSet".equals(m.getNameAsString()))
.findFirst()
.orElseThrow(IllegalStateException::new);
Expression keyArg = redisSet.getArgument(0);
String pattern = new RedisKeyResolver(index).resolve(
keyArg, clazz, index.get("demo.CombinationRepo"));
assertEquals("data_analysis:combination_query_key:*:*", pattern);
}
@Test
void localReassignmentUsesNearestBindingBeforeUse() {
String source = ""
+ "package demo;\n"
+ "public class ReassignRepo {\n"
+ " private static final String A = \"prefix:a:%s\";\n"
+ " private static final String B = \"prefix:b:%s\";\n"
+ " public void write(String id) {\n"
+ " String key = String.format(A, id);\n"
+ " key = String.format(B, id);\n"
+ " redisSet(key);\n"
+ " }\n"
+ " void redisSet(String k) {}\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
.filter(m -> "redisSet".equals(m.getNameAsString()))
.findFirst()
.orElseThrow(IllegalStateException::new);
String pattern = new RedisKeyResolver(index).resolve(
redisSet.getArgument(0), clazz, index.get("demo.ReassignRepo"));
assertEquals("prefix:b:*", pattern);
}
@Test
void staticConstantStillPreferredOverLocalNameCollision() {
// 局部变量名与静态常量同名时:仍优先静态常量(保持旧语义)
String source = ""
+ "package demo;\n"
+ "public class Collision {\n"
+ " private static final String KEY = \"const:prefix:%s\";\n"
+ " public void write(String id) {\n"
+ " String KEY = \"local:\" + id;\n"
+ " redisSet(KEY);\n"
+ " }\n"
+ " void redisSet(String k) {}\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
.filter(m -> "redisSet".equals(m.getNameAsString()))
.findFirst()
.orElseThrow(IllegalStateException::new);
String pattern = new RedisKeyResolver(index).resolve(
redisSet.getArgument(0), clazz, index.get("demo.Collision"));
assertEquals("const:prefix:%s", pattern);
}
} }

View File

@@ -5,6 +5,7 @@ import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity; import com.codechecker.cache.diff.Severity;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -56,12 +57,12 @@ class ReportBuilderTest {
key.getFieldDetails().add(addedErr); key.getFieldDetails().add(addedErr);
report.getKeyChanges().add(key); report.getKeyChanges().add(key);
String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report); String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("- Key --> `saas:period-config:migration:current`")); assertTrue(md.contains("- Key --> `saas:period-config:migration:current`"));
assertFalse(md.contains("key 无法解析)")); assertFalse(md.contains("key 无法解析)"));
assertTrue(md.contains("> **位置**: `SaasPeriodConfigMigrationRedisSupport#putCurrent:41`")); assertTrue(md.contains("> **位置**: SaasPeriodConfigMigrationRedisSupport#putCurrent:41"));
assertTrue(md.contains("> **类型**: `MigrationCurrentVo`")); assertTrue(md.contains("> **类型**: MigrationCurrentVo"));
int oldSection = md.indexOf("> **value值由**"); int oldSection = md.indexOf("> **value值由**");
int newSection = md.indexOf("> **变更为:**"); int newSection = md.indexOf("> **变更为:**");
@@ -111,6 +112,174 @@ class ReportBuilderTest {
assertFalse(newMd.startsWith("`")); assertFalse(newMd.startsWith("`"));
} }
@Test
void typeChangedHighlightedGrayInBothSkeletons() {
SchemaChange typeChanged = new SchemaChange(ChangeType.TYPE_CHANGED);
typeChanged.setFieldPath("amount");
typeChanged.setOldValue("BigDecimal/NUMBER");
typeChanged.setNewValue("String/STRING");
typeChanged.setMessage("字段 amount 类型由 BigDecimal/NUMBER 变为 String/STRING");
String oldJson = "{\"amount\":0,\"name\":\"\"}";
String newJson = "{\"amount\":\"\",\"name\":\"\"}";
List<SchemaChange> details = Collections.singletonList(typeChanged);
Set<String> oldPaths = SkeletonAnnotator.pathsForOldSkeleton(details);
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(details);
assertTrue(oldPaths.isEmpty());
assertTrue(newGreen.isEmpty());
assertEquals(Collections.singleton("amount"), typeGray);
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldPaths, typeGray);
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, typeGray);
assertTrue(oldMd.contains("<font color=\"comment\">\"amount\":0</font>"));
assertFalse(oldMd.contains("<font color=\"warning\">\"amount\""));
assertFalse(oldMd.contains("<font color=\"comment\">\"name\":\"\"</font>"));
assertTrue(newMd.contains("<font color=\"comment\">\"amount\":\"\"</font>"));
assertFalse(newMd.contains("<font color=\"info\">\"amount\""));
assertFalse(newMd.contains("<font color=\"warning\">\"amount\""));
assertFalse(newMd.contains("<font color=\"comment\">\"name\":\"\"</font>"));
CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("cache:amount");
key.setWriteLocation("Demo#put:1");
key.setValueType("AmountVo");
key.setOldSkeletonJson(oldJson);
key.setNewSkeletonJson(newJson);
key.getFieldDetails().add(typeChanged);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
int oldSection = md.indexOf("> **value值由**");
int newSection = md.indexOf("> **变更为:**");
assertTrue(oldSection >= 0);
assertTrue(newSection > oldSection);
String oldPart = md.substring(oldSection, newSection);
String newPart = md.substring(newSection);
assertTrue(oldPart.contains("<font color=\"comment\">\"amount\":0</font>"));
assertTrue(newPart.contains("<font color=\"comment\">\"amount\":\"\"</font>"));
assertFalse(newPart.contains("<font color=\"info\">\"amount\""));
assertFalse(oldPart.contains("<font color=\"warning\">\"amount\""));
assertTrue(md.contains("> **类型变更**: amount <font color=\"warning\">BigDecimal → String</font>"),
"应包含类型变更摘要行,实际 markdown:\n" + md);
}
@Test
void wrapperAddedHighlightedBeforeNestedMovedFields() {
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_ADDED);
wrapper.setFieldPath("vo");
wrapper.setMessage("新增包装层 vo");
SchemaChange movedDb = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
movedDb.setFieldPath("vo.dbName");
movedDb.setOldValue("dbName");
movedDb.setNewValue("vo.dbName");
SchemaChange movedLink = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
movedLink.setFieldPath("vo.linkList[].id");
movedLink.setOldValue("linkList[].id");
movedLink.setNewValue("vo.linkList[].id");
List<SchemaChange> details = new ArrayList<>();
details.add(wrapper);
details.add(movedDb);
details.add(movedLink);
String oldJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}";
String newJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}";
Set<String> wrappers = SkeletonAnnotator.pathsForWrapperAdded(details);
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
assertEquals(Collections.singleton("vo"), wrappers);
assertTrue(newGreen.contains("vo.dbName"));
assertFalse(newGreen.contains("vo"), "包装层不应再混入普通新增路径");
// 先标包装层:整段 vo 对象应绿;子路径随后因已在 font 内可跳过
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, wrappers);
assertTrue(newMd.contains("<font color=\"info\">\"vo\":{"), newMd);
assertTrue(newMd.contains("</font>"), newMd);
CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("tenant:db:content:*");
key.setWriteLocation("TenantDbContentCacheHelper#cacheNotFound:62");
key.setValueType("TenantVO");
key.setOldSkeletonJson(oldJson);
key.setNewSkeletonJson(newJson);
key.getFieldDetails().addAll(details);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
int newSection = md.indexOf("> **变更为:**");
assertTrue(newSection >= 0);
String newPart = md.substring(newSection);
assertTrue(newPart.contains("<font color=\"info\">\"vo\":{"),
"包装层 vo 应整段绿色,实际:\n" + newPart);
}
@Test
void wrapperRemovedHighlightedBeforeNestedMovedFieldsOnOldSkeleton() {
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_REMOVED);
wrapper.setFieldPath("vo");
wrapper.setOldValue("vo");
wrapper.setMessage("删除包装层 vo");
SchemaChange movedDb = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
movedDb.setFieldPath("dbName");
movedDb.setOldValue("vo.dbName");
movedDb.setNewValue("dbName");
SchemaChange movedLink = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
movedLink.setFieldPath("linkList[].id");
movedLink.setOldValue("vo.linkList[].id");
movedLink.setNewValue("linkList[].id");
List<SchemaChange> details = new ArrayList<>();
details.add(wrapper);
details.add(movedDb);
details.add(movedLink);
String oldJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}";
String newJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}";
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(details);
Set<String> oldOrange = SkeletonAnnotator.pathsForOldSkeleton(details);
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
assertEquals(Collections.singleton("vo"), wrappersRemoved);
assertTrue(oldOrange.contains("vo.dbName"));
assertTrue(newGreen.contains("dbName"));
assertFalse(newGreen.contains("vo"));
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldOrange, null, wrappersRemoved);
assertTrue(oldMd.contains("<font color=\"warning\">\"vo\":{"), oldMd);
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, null);
assertTrue(newMd.contains("<font color=\"info\">\"dbName\":\"\"</font>"), newMd);
assertFalse(newMd.contains("<font color=\"info\">\"vo\""), newMd);
CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("tenant:db:content:");
key.setWriteLocation("TenantDbContentCacheHelper#cacheNotFound:62");
key.setValueType("TenantVO");
key.setOldSkeletonJson(oldJson);
key.setNewSkeletonJson(newJson);
key.getFieldDetails().addAll(details);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
int oldSection = md.indexOf("> **value值由**");
int newSection = md.indexOf("> **变更为:**");
assertTrue(oldSection >= 0 && newSection > oldSection);
assertTrue(md.substring(oldSection, newSection).contains("<font color=\"warning\">\"vo\":{"),
"旧骨架应整段橙标 vo实际:\n" + md);
}
@Test @Test
void consoleContainsFieldDetailsAndWecomMarkdown() { void consoleContainsFieldDetailsAndWecomMarkdown() {
CheckReport report = new CheckReport(); CheckReport report = new CheckReport();
@@ -133,13 +302,13 @@ class ReportBuilderTest {
key.getFieldDetails().add(detail); key.getFieldDetails().add(detail);
report.getKeyChanges().add(key); report.getKeyChanges().add(key);
String console = new ReportBuilder("[缓存结构变更]").toConsole(report); String console = new ReportBuilder("[序列化结构变更]").toConsole(report);
assertTrue(console.contains("======== 字段明细 ========")); assertTrue(console.contains("======== 字段明细 ========"));
assertTrue(console.contains("**删除字段**: x")); assertTrue(console.contains("**删除字段**: x"));
assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>")); assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>"));
assertFalse(console.contains("<font color=\"info\">\"x\":\"\"</font>")); assertFalse(console.contains("<font color=\"info\">\"x\":\"\"</font>"));
assertTrue(console.contains("> **位置**: `")); assertTrue(console.contains("> **位置**: "));
assertTrue(console.contains("> **类型**: `")); assertTrue(console.contains("> **类型**: "));
assertTrue(console.contains("> **value值由**")); assertTrue(console.contains("> **value值由**"));
assertTrue(console.contains("> **变更为:**")); assertTrue(console.contains("> **变更为:**"));
assertFalse(console.contains("`{\"x\":\"\"}`")); assertFalse(console.contains("`{\"x\":\"\"}`"));
@@ -161,10 +330,10 @@ class ReportBuilderTest {
key.getFieldDetails().add(added); key.getFieldDetails().add(added);
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 无法解析)</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`"));
} }
@@ -185,7 +354,7 @@ class ReportBuilderTest {
report.getKeyChanges().add(simpleKey("k-a", "{\"a\":\"\"}", "{\"a\":\"\",\"x\":\"\"}", "x")); report.getKeyChanges().add(simpleKey("k-a", "{\"a\":\"\"}", "{\"a\":\"\",\"x\":\"\"}", "x"));
report.getKeyChanges().add(simpleKey("k-b", "{\"b\":\"\"}", "{\"b\":\"\",\"y\":\"\"}", "y")); report.getKeyChanges().add(simpleKey("k-b", "{\"b\":\"\"}", "{\"b\":\"\",\"y\":\"\"}", "y"));
List<String> messages = new ReportBuilder("[缓存结构变更]").toWeComMessages(report); List<String> messages = new ReportBuilder("[序列化结构变更]").toWeComMessages(report);
assertEquals(1, messages.size()); assertEquals(1, messages.size());
assertTrue(messages.get(0).contains("k-a")); assertTrue(messages.get(0).contains("k-a"));
assertTrue(messages.get(0).contains("k-b")); assertTrue(messages.get(0).contains("k-b"));
@@ -200,7 +369,7 @@ class ReportBuilderTest {
report.getKeyChanges().add(simpleKey("fat-key-1", fat, fat + "1", null)); report.getKeyChanges().add(simpleKey("fat-key-1", fat, fat + "1", null));
report.getKeyChanges().add(simpleKey("fat-key-2", fat, fat + "2", null)); report.getKeyChanges().add(simpleKey("fat-key-2", fat, fat + "2", null));
List<String> messages = new ReportBuilder("[缓存结构变更]").toWeComMessages(report); List<String> messages = new ReportBuilder("[序列化结构变更]").toWeComMessages(report);
assertEquals(2, messages.size(), "超长应按 key 拆成 2 条"); assertEquals(2, messages.size(), "超长应按 key 拆成 2 条");
assertTrue(messages.get(0).contains("fat-key-1")); assertTrue(messages.get(0).contains("fat-key-1"));
assertFalse(messages.get(0).contains("fat-key-2")); assertFalse(messages.get(0).contains("fat-key-2"));
@@ -209,13 +378,31 @@ class ReportBuilderTest {
assertTrue(ReportBuilder.utf8Bytes(messages.get(0)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES); assertTrue(ReportBuilder.utf8Bytes(messages.get(0)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES);
assertTrue(ReportBuilder.utf8Bytes(messages.get(1)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES); assertTrue(ReportBuilder.utf8Bytes(messages.get(1)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES);
// 抬头在每条中重复 // 抬头在每条中重复
assertTrue(messages.get(0).contains("## [缓存结构变更] jnpf-java-cloud")); assertTrue(messages.get(0).contains("## [序列化结构变更] jnpf-java-cloud"));
assertTrue(messages.get(1).contains("## [缓存结构变更] jnpf-java-cloud")); assertTrue(messages.get(1).contains("## [序列化结构变更] jnpf-java-cloud"));
String console = new ReportBuilder("[缓存结构变更]").toConsole(report); String console = new ReportBuilder("[序列化结构变更]").toConsole(report);
assertTrue(console.contains("超长已按 key 拆为 2 条")); assertTrue(console.contains("超长已按 key 拆为 2 条"));
} }
@Test
void wildcardAsteriskInKeyEscapedForWeCom() {
assertEquals("data_analysis:combination_query_key::",
ReportBuilder.escapeWeComCode("data_analysis:combination_query_key:*:*"));
CheckReport report = baseReport();
KeyStructureChange key = simpleKey(
"data_analysis:combination_query_key:*:*",
"{\"a\":0}", "{\"a\":0,\"b\":0}", "b");
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("- Key --> `data_analysis:combination_query_key::`"),
"通配符 * 应转成全角*,实际:\n" + md);
assertFalse(md.contains("combination_query_key:*:*"),
"不应再输出未转义的 *:*, 实际:\n" + md);
}
private static CheckReport baseReport() { private static CheckReport baseReport() {
CheckReport report = new CheckReport(); CheckReport report = new CheckReport();
report.setRepository("jnpf-java-cloud"); report.setRepository("jnpf-java-cloud");

View File

@@ -81,4 +81,21 @@ class JavaSchemaExtractorTest {
assertTrue(AnnotationSupport.ignoredProperties(type).contains("a")); assertTrue(AnnotationSupport.ignoredProperties(type).contains("a"));
assertTrue(AnnotationSupport.ignoredProperties(type).contains("b")); assertTrue(AnnotationSupport.ignoredProperties(type).contains("b"));
} }
@Test
void bigDecimalMappedAsNumberNotString() {
String source = ""
+ "package demo;\n"
+ "import java.math.BigDecimal;\n"
+ "public class MoneyVo {\n"
+ " private BigDecimal amount;\n"
+ " private String currency;\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
TypeSchema schema = new JavaSchemaExtractor(index, 8).extract("demo.MoneyVo");
assertEquals(JsonType.NUMBER, schema.getFields().get("amount").getJsonType());
assertEquals("BigDecimal", schema.getFields().get("amount").getJavaType());
assertEquals(JsonType.STRING, schema.getFields().get("currency").getJsonType());
}
} }

View File

@@ -80,4 +80,31 @@ class SkeletonJsonRendererTest {
"截断后仍应保留改动相关字段: " + truncated); "截断后仍应保留改动相关字段: " + truncated);
assertEquals(true, truncated.length() >= 10); assertEquals(true, truncated.length() >= 10);
} }
@Test
void numberPlaceholdersDistinguishIntegerFloatAndBigDecimal() {
TypeSchema schema = new TypeSchema("MoneyVo");
schema.add(new FieldSchema("count", JsonType.NUMBER, "Integer"));
schema.add(new FieldSchema("rate", JsonType.NUMBER, "Double"));
schema.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
schema.add(new FieldSchema("score", JsonType.NUMBER, "Float"));
String json = new SkeletonJsonRenderer().render(schema);
assertTrue(json.contains("\"count\":0"), json);
assertTrue(json.contains("\"rate\":0.0"), json);
assertTrue(json.contains("\"amount\":0.00"), json);
assertTrue(json.contains("\"score\":0.0"), json);
}
@Test
void integerToBigDecimalSkeletonsLookDifferent() {
TypeSchema oldS = new TypeSchema("A");
oldS.add(new FieldSchema("amount", JsonType.NUMBER, "Integer"));
TypeSchema newS = new TypeSchema("A");
newS.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
SkeletonJsonRenderer renderer = new SkeletonJsonRenderer();
assertEquals("{\"amount\":0}", renderer.render(oldS));
assertEquals("{\"amount\":0.00}", renderer.render(newS));
}
} }