diff --git a/maven-cw-elevator-application/cw-elevator-application-service/docs/INDEX.md b/maven-cw-elevator-application/cw-elevator-application-service/docs/INDEX.md index 7fb34468..2e35f4e2 100644 --- a/maven-cw-elevator-application/cw-elevator-application-service/docs/INDEX.md +++ b/maven-cw-elevator-application/cw-elevator-application-service/docs/INDEX.md @@ -12,6 +12,7 @@ ## 与仓库级文档的关系 - 全仓约定、走查、接口不变等说明见仓库根下 **`../../../docs/`**(相对本文件)。 +- 发布包与**历史 JAR 接口对拍**(计划/报告)见聚合工程目录 **`../../docs/elevator-api-parity/PLAN.md`**、脚本 `../../scripts/run_elevator_parity.sh`。 - 本目录专注 **本 service 模块内** 类职责与业务流程梳理,不替代对外 API 合同文档。 ## 分册导航 diff --git a/maven-cw-elevator-application/docs/elevator-api-parity/PLAN.md b/maven-cw-elevator-application/docs/elevator-api-parity/PLAN.md new file mode 100644 index 00000000..a08d0998 --- /dev/null +++ b/maven-cw-elevator-application/docs/elevator-api-parity/PLAN.md @@ -0,0 +1,44 @@ +# 新旧版本发布包:接口同构验证计划 + +## 1. 目标 + +- **新构建物**:`cw-elevator-application-starter` 经 `mvn package` 产出的 `cw-elevator-application-2.0.0.jar`。 +- **历史基线**(约定路径,若不存在需单独拷贝): + `.../cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar`。 +- **成功标准**:在**相同运行配置与依赖环境**下,对同一批 HTTP 请求,两实例返回的 **HTTP 状态码、JSON 结构、业务 `code`(`CloudwalkResult`)** 一致;可约定忽略字段(时间戳、随机 `requestId` 等)在报告中注明。 + +## 2. 约束与先决条件 + +1. **双进程**:旧版默认端口 `18080`,新版 `18081`;均通过**同一套**外置或环境变量 `spring.config` 指向上游/库(与现网联调一致时才有可比性)。 +2. **无历史 JAR**:本仓库中若不存在上述 `*.jar`,仅完成构建与**框架/离线用例**;等效性验证需在拿到 JAR 后执行 `scripts/run_elevator_parity.sh`。 +3. **健康检查**:优先 `GET /actuator/health`;若基线为 Spring Boot 1.5 且 `management` 基路径不同,由脚本 `tools/elevator_api_parity/parity/health.py` 自动探测回退。 + +## 3. 子任务分解 + +| 序号 | 任务 | 产出 | +|------|------|------| +| T1 | Maven 发布包构建 | `target/cw-elevator-application-2.0.0.jar` | +| T2 | 端点目录与用例体 | `tools/elevator_api_parity/api_catalog.json`、fixtures | +| T3 | 双端 HTTP 对拍与比较 | `parity/*` + `pytest` | +| T4 | 一键脚本与报告 | `scripts/run_elevator_parity.sh` → `report/parity-*.md` | +| T5 | 在联调环境实跑、签字发布 | 人工/运维 | + +## 4. 接口范围(与源码 Controller 一致) + +- `/elevator/person/*`(含 `/add/visitor` 等) +- `/elevator/passRule/*` +- `/elevator/device/*`、`/elevator/restructure/*` +- `/intelligent/acs/elevator/record/*`(`POST` 为主) +- 设备通道 `/device/v2/*`(`39201` 等,按业务场景启用) + +具体路径与请求体以 `api_catalog.json` 为准,可按现网场景追加 `fixtures/*.json`。 + +## 5. 非目标 + +- 不替代集成环境全链路压测、安全扫描。 +- 不强制在一台空库机子上与线上一致(需**相同数据与上游**才可比)。 + +## 6. 与本文档配套脚本 + +- 见 `maven-cw-elevator-application/tools/elevator_api_parity/README.md`。 +- 报告模板:`docs/elevator-api-parity/REPORT-TEMPLATE.md`(实跑时由 `generate_report.py` 自动填充为 `tools/elevator_api_parity/report/parity-*.md`)。 diff --git a/maven-cw-elevator-application/docs/elevator-api-parity/REPORT-TEMPLATE.md b/maven-cw-elevator-application/docs/elevator-api-parity/REPORT-TEMPLATE.md new file mode 100644 index 00000000..00b81fc1 --- /dev/null +++ b/maven-cw-elevator-application/docs/elevator-api-parity/REPORT-TEMPLATE.md @@ -0,0 +1,28 @@ +# 电梯应用 API 新旧版本对拍报告 + +- **报告生成时间**:(由脚本自动写入) +- **新版权益端 Base URL**: +- **历史基线端 Base URL**: + +## 结论摘要 + +| 项 | 值 | +|----|-----| +| 总用例 | | +| 通过 | | +| 失败/跳过 | | +| 可上线建议 | **通过/不通过/条件通过**(人工确认) | + +## 逐条用例 + +| 名称 | 方法+路径 | HTTP(旧) | HTTP(新) | JSON 等值 | 备注 | +|------|-----------|----------|----------|------------|------| +| (表格由 `generate_report.py` 从 pytest 结果填充) | | | | | | + +## 已忽略/归一化项 + +- `timestamp`、各层 `message` 文案差异、UUID 等(如策略允许可在 `parity_config.yaml` 配置)。 + +## 未启动实例时的说明 + +- 若出现「基线/新版无法连接健康检查」,对拍不执行,需在本机或联调机启动两 JAR 后重跑。 diff --git a/maven-cw-elevator-application/docs/elevator-api-parity/SAMPLE-REPORT.md b/maven-cw-elevator-application/docs/elevator-api-parity/SAMPLE-REPORT.md new file mode 100644 index 00000000..a2f0bada --- /dev/null +++ b/maven-cw-elevator-application/docs/elevator-api-parity/SAMPLE-REPORT.md @@ -0,0 +1,19 @@ +# 电梯应用 API 新旧对拍报告(示例) + +- **时间**: 2026-04-25T12:00:00 +- **基线(旧) URL**: `http://127.0.0.1:18080` +- **新构建 URL**: `http://127.0.0.1:18081` + +## 用例 + +| 用例 | 方法+路径 | HTTP(旧) | HTTP(新) | 等值 | 说明 | +| ---- | -------- | -------- | -------- | ---- | ---- | +| person_add_visitor_min | `POST /elevator/person/add/visitor` | 200 | 200 | Y | 双端 `code` 一致(联调同库同配置时) | +| person_detail | `POST /elevator/person/detail` | 200 | 200 | Y | 同上 | +| passrule_page | `POST /elevator/passRule/page` | 200 | 200 | Y | 同上 | +| record_page | `POST /intelligent/acs/elevator/record/page` | 200 | 200 | Y | 同上 | + +## 汇总 + +- 通过: 4,不一致: 0。 +- **上线前请人工与联调/业务确认**(本表为结构示例,实跑见 `tools/elevator_api_parity/report/parity-*.md`)。 diff --git a/maven-cw-elevator-application/scripts/run_elevator_parity.sh b/maven-cw-elevator-application/scripts/run_elevator_parity.sh new file mode 100755 index 00000000..6c3a4208 --- /dev/null +++ b/maven-cw-elevator-application/scripts/run_elevator_parity.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# 本脚本位于 maven-cw-elevator-application/scripts/:先打新包,再跑对拍用例与报告 +set -e +REPO="$(cd "$(dirname "$0")/.." && pwd)" +TOOL="${REPO}/tools/elevator_api_parity" +JAR_NEW="${REPO}/cw-elevator-application-starter/target/cw-elevator-application-2.0.0.jar" +JAR_LEGACY_DEFAULT="$(cd "$REPO/.." && pwd)/cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar" +JAR_LEGACY="${ELEVATOR_JAR_LEGACY:-$JAR_LEGACY_DEFAULT}" + +if [[ -z "${JAVA_8:-}" ]]; then + for d in /usr/lib/jvm/java-8-openjdk-amd64 /usr/lib/jvm/java-1.8.0-openjdk; do + if [[ -x "$d/bin/java" ]]; then + export JAVA_8="$d" + break + fi + done +fi +: "${JAVA_8:=/usr/lib/jvm/java-8-openjdk-amd64}" +export JAVA_HOME="$JAVA_8" +export PATH="$JAVA_HOME/bin:$PATH" + +cd "$REPO" +echo "==> mvn package (skip tests)" +mvn -q -DskipTests clean package +echo "==> 新 JAR: $JAR_NEW [$(test -f "$JAR_NEW" && echo OK || echo 缺失)]" +echo "==> 基线 JAR(路径): $JAR_LEGACY [$(test -f "$JAR_LEGACY" && echo 存在 || echo 不存在,可设ELEVATOR_JAR_LEGACY)]" +echo " (对拍为 HTTP; 需另开终端分别 java -jar --server.port=18080/18081 与相同配置)" +(cd "$TOOL" && pip install -q -r requirements.txt 2>/dev/null || pip3 install -q -r requirements.txt 2>/dev/null || true) +echo "==> 单元(无联调): test_unit_compare" +export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 +(cd "$TOOL" && python3 -m pytest tests/test_unit_compare.py -q --tb=short) +echo "==> 对拍(无两实例时跳过 test_parity_endpoints): 全部" +(cd "$TOOL" && python3 -m pytest tests/ -q --tb=line) +echo "报告: $TOOL/report/ (对拍有执行且成功时由 pytest 会话写出 parity-*.md)" diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/.gitignore b/maven-cw-elevator-application/tools/elevator_api_parity/.gitignore new file mode 100644 index 00000000..ab09d337 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.pytest_cache/ +report/parity-*.md diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/README.md b/maven-cw-elevator-application/tools/elevator_api_parity/README.md new file mode 100644 index 00000000..77c01cc4 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/README.md @@ -0,0 +1,44 @@ +# elevator_api_parity — 新旧 JAR 接口对拍 + +## 环境 + +- Python 3.8+ +- 安装:`pip install -r requirements.txt`(在**本目录**下执行) + +## 环境说明 + +本机若安装过 `allure_pytest` 等全局 pytest 插件且与 Python 版本冲突,请执行: + +`export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1`(`run_elevator_parity.sh` 已自动设置)后再跑 `pytest`。 + +## 快速使用 + +1. 构建新 JAR(在 reactor 根目录、JDK8) + `mvn -DskipTests clean package` + 得到:`../cw-elevator-application-starter/target/cw-elevator-application-2.0.0.jar` +2. 将历史 JAR 放到 `cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar` 或设 `ELEVATOR_JAR_LEGACY`。 +3. 准备**与现网相同**的 `application.yml`(或目录),如 `ELEVATOR_SPRING_CONFIG=file:/path/to/application-elevator.yml`。 +4. 从仓库**反编译**根或本目录执行: + `../../scripts/run_elevator_parity.sh`(见该脚本内环境变量说明) + +或手动启动两实例后: + +```bash +export ELEVATOR_BASE_OLD=http://127.0.0.1:18080 +export ELEVATOR_BASE_NEW=http://127.0.0.1:18081 +export ELEVATOR_HEADER_BUSINESSID=... # 与现网/联调一致 +# ... +cd tools/elevator_api_parity +python -m pytest tests/ -v --tb=short +``` + +## 配置 + +- `api_catalog.json`:对拍端点、方法、fixture 文件名、说明。 +- `fixtures/*.json`:各接口请求体。 +- 归一化忽略键可扩展 `parity/compare.py` 中 `DEFAULT_STRIP_PATHS`(如 `data.rows` 内动态字段,谨慎)。 + +## 报告 + +- `report/parity-YYYYMMDD-HHMMSS.md`:实跑时由 `conftest` 的 session hook 与 `report/generate_report.py` 组合生成。 +- JUnit(可选):`--junitxml=report/junit.xml` diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/api_catalog.json b/maven-cw-elevator-application/tools/elevator_api_parity/api_catalog.json new file mode 100644 index 00000000..02fa4003 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/api_catalog.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "endpoints": [ + { + "id": "person_add_visitor_min", + "name": "访客派梯-最小体(多依赖业务失败,仅比对 HTTP+业务code)", + "method": "POST", + "path": "/elevator/person/add/visitor", + "fixture": "person_add_visitor_min.json", + "compare_mode": "code_only" + }, + { + "id": "person_detail", + "name": "人员详情-最小分页体", + "method": "POST", + "path": "/elevator/person/detail", + "fixture": "person_detail_min.json", + "compare_mode": "code_only" + }, + { + "id": "passrule_page", + "name": "通行规则-分页", + "method": "POST", + "path": "/elevator/passRule/page", + "fixture": "passrule_page.json", + "compare_mode": "code_only" + }, + { + "id": "record_page", + "name": "通行记录-分页", + "method": "POST", + "path": "/intelligent/acs/elevator/record/page", + "fixture": "record_page.json", + "compare_mode": "code_only" + } + ] +} diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/conftest.py b/maven-cw-elevator-application/tools/elevator_api_parity/conftest.py new file mode 100644 index 00000000..93672553 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/conftest.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +import requests + +from parity.client import can_reach_both, default_headers + +_DIR = Path(__file__).resolve().parent + + +def pytest_configure(config): + config._parity_rows = [] # type: ignore[attr-defined] + + +def pytest_addoption(parser): + parser.addoption( + "--base-old", + default=os.environ.get("ELEVATOR_BASE_OLD", "http://127.0.0.1:18080"), + ) + parser.addoption( + "--base-new", + default=os.environ.get("ELEVATOR_BASE_NEW", "http://127.0.0.1:18081"), + ) + + +@pytest.fixture(scope="session") +def base_old(request): + return str(request.config.getoption("--base-old")).rstrip("/") + + +@pytest.fixture(scope="session") +def base_new(request): + return str(request.config.getoption("--base-new")).rstrip("/") + + +@pytest.fixture(scope="session") +def session_http(): + s = requests.Session() + s.headers.update(default_headers()) + yield s + s.close() + + +@pytest.fixture(scope="session") +def two_instances_ready(base_old, base_new, session_http, request): + ok, msg = can_reach_both(base_old, base_new, session_http) + require = os.environ.get("ELEVATOR_PARITY_REQUIRE_LIVE", "") + if not ok and not require: + pytest.skip(f"双端健康检查不通过(跳过用例): {msg}") + if not ok and require: + pytest.fail(f"ELEVATOR_PARITY_REQUIRE_LIVE=1 且双端不可达: {msg}") + return True + + +def pytest_sessionfinish(session, exitstatus): + rows = getattr(session.config, "_parity_rows", None) + if not rows: + return + try: + from report import generate_report + + p = _DIR / "report" / generate_report.timestamped_name("parity") + p.parent.mkdir(parents=True, exist_ok=True) + generate_report.write_file( + p, + str(session.config.getoption("--base-old", default="")), + str(session.config.getoption("--base-new", default="")), + rows, + ) + print(f"\n[parity] 报告: {p}") + except Exception as e: + print(f"\n[parity] 报告未生成: {e}") + + +def load_catalog() -> dict: + from parity import catalog_loader + + return catalog_loader.load() diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/passrule_page.json b/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/passrule_page.json new file mode 100644 index 00000000..67bbfef7 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/passrule_page.json @@ -0,0 +1,4 @@ +{ + "pageNo": 1, + "pageSize": 1 +} diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/person_add_visitor_min.json b/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/person_add_visitor_min.json new file mode 100644 index 00000000..dfddc56a --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/person_add_visitor_min.json @@ -0,0 +1,7 @@ +{ + "visitorId": "", + "personId": "", + "begVisitorTime": 1, + "endVisitorTime": 1, + "floorIds": [] +} diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/person_detail_min.json b/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/person_detail_min.json new file mode 100644 index 00000000..f9190931 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/person_detail_min.json @@ -0,0 +1,8 @@ +{ + "currentPage": 1, + "rowsOfPage": 1, + "parentId": "", + "zoneId": "", + "ruleId": "", + "personName": "" +} diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/record_page.json b/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/record_page.json new file mode 100644 index 00000000..fa711040 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/fixtures/record_page.json @@ -0,0 +1,6 @@ +{ + "currentPage": 1, + "rowsOfPage": 1, + "startTime": 1, + "endTime": 2 +} diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/parity/__init__.py b/maven-cw-elevator-application/tools/elevator_api_parity/parity/__init__.py new file mode 100644 index 00000000..e02cd22b --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/parity/__init__.py @@ -0,0 +1 @@ +"""Parity: HTTP client, health probe, JSON compare for elevator JARs.""" diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/parity/catalog_loader.py b/maven-cw-elevator-application/tools/elevator_api_parity/parity/catalog_loader.py new file mode 100644 index 00000000..da7ab427 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/parity/catalog_loader.py @@ -0,0 +1,8 @@ +import json +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent + + +def load() -> dict: + return json.loads((_ROOT / "api_catalog.json").read_text(encoding="utf-8")) diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/parity/client.py b/maven-cw-elevator-application/tools/elevator_api_parity/parity/client.py new file mode 100644 index 00000000..a19baf56 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/parity/client.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from typing import Any, Optional + +import requests + +from . import compare +from .health import probe_healthy + + +@dataclass +class ParityResult: + name: str + method: str + path: str + old_status: int + new_status: int + old_text: str + new_text: str + match: bool + message: str + + +def default_headers() -> dict[str, str]: + h: dict[str, str] = { + "Content-Type": "application/json", + } + m = { + "businessid": "ELEVATOR_HEADER_BUSINESSID", + "loginid": "ELEVATOR_HEADER_LOGINID", + "platformuserid": "ELEVATOR_HEADER_PLATFORMUSERID", + "authorization": "ELEVATOR_HEADER_AUTHORIZATION", + "applicationid": "ELEVATOR_HEADER_APPLICATIONID", + } + for hdr, evar in m.items(): + v = os.environ.get(evar, "").strip() + if v: + h[hdr] = v + return h + + +def call_both( + name: str, + method: str, + path: str, + body: Any, + base_old: str, + base_new: str, + extra_headers: Optional[dict] = None, + compare_mode: str = "deep", + session: requests.Session | None = None, +) -> ParityResult: + s = session or requests.Session() + h = {**default_headers(), **(extra_headers or {})} + url_o = base_old.rstrip("/") + path + url_n = base_new.rstrip("/") + path + if body is None: + data = None + else: + data = body if isinstance(body, str) else json.dumps(body, ensure_ascii=False) + m = method.upper() + if m == "GET": + r_old = s.get(url_o, headers=h, timeout=60) + r_new = s.get(url_n, headers=h, timeout=60) + else: + r_old = s.post(url_o, headers=h, data=data, timeout=120) + r_new = s.post(url_n, headers=h, data=data, timeout=120) + ot, nt = r_old.text, r_new.text + st_o, st_n = r_old.status_code, r_new.status_code + msg = "" + + if compare_mode == "status_only": + m_ok = st_o == st_n + if not m_ok: + msg = f"http {st_o} vs {st_n}" + elif compare_mode == "code_only": + msg = "" + oj, nj = _safe_json(ot), _safe_json(nt) + c1, c2 = compare.business_code(oj), compare.business_code(nj) + m_ok = st_o == st_n + if m_ok and c1 is not None and c2 is not None: + m_ok = c1 == c2 + elif m_ok and c1 is None and c2 is None and st_o == st_n and 400 <= st_o < 600: + m_ok = True + msg = "http status only (no parseable JSON body)" + elif m_ok and (c1 is None) != (c2 is None): + m_ok = False + if not m_ok and not msg: + msg = compare.describe_diff(ot, nt, "code_only") + elif m_ok and not msg: + msg = "" + else: + m_ok = st_o == st_n + oj, nj = _safe_json(ot), _safe_json(nt) + if oj is not None and nj is not None and m_ok: + m_ok = compare.normalize(oj) == compare.normalize(nj) + elif m_ok and (ot or "") != (nt or ""): + m_ok = (ot or "").strip() == (nt or "").strip() + if not m_ok: + msg = compare.describe_diff(ot, nt, "deep") or f"http {st_o}/{st_n} or body mismatch" + + return ParityResult( + name=name, + method=m, + path=path, + old_status=st_o, + new_status=st_n, + old_text=ot, + new_text=nt, + match=m_ok, + message=msg, + ) + + +def _safe_json(text: str) -> Any: + if not (text or "").strip(): + return None + try: + return compare.parse_json_loose(text) + except Exception: + return None + + +def can_reach_both( + base_old: str, base_new: str, s: requests.Session | None = None +) -> tuple[bool, str]: + s = s or requests.Session() + p1, ok1, _ = probe_healthy(base_old, s) + p2, ok2, _ = probe_healthy(base_new, s) + if ok1 and ok2: + return True, f"ok old health path~{p1} new probed" + return False, f"health old ok={ok1} new ok={ok2} (paths like {p1})" diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/parity/compare.py b/maven-cw-elevator-application/tools/elevator_api_parity/parity/compare.py new file mode 100644 index 00000000..45c3ff5a --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/parity/compare.py @@ -0,0 +1,94 @@ +""" +Compare two JSON response bodies. CloudwalkResult: compare top-level `code` when +both parse as objects; also deep-compare with optional key stripping. +""" + +from __future__ import annotations + +import copy +import json +from typing import Any, List, Set + +# JSONPath-like: tuple of keys in order for nesting +DEFAULT_STRIP_PATHS: Set[tuple] = { + # ("data", "ts"), +} + + +def _strip(obj: Any, rules: Set[tuple], prefix: tuple) -> Any: + if not isinstance(obj, (dict, list)): + return obj + if isinstance(obj, list): + return [_strip(x, rules, prefix + (i,)) for i, x in enumerate(obj)] + out = copy.deepcopy(obj) + for k, v in list(obj.items()) if isinstance(obj, dict) else []: + p = prefix + (k,) + if p in rules: + if k in out: + del out[k] + continue + if isinstance(v, (dict, list)): + out[k] = _strip(v, rules, p) + return out + + +def parse_json_loose(text: str) -> Any: + if not (text or "").strip(): + return None + return json.loads(text) + + +def normalize( + data: Any, + strip_paths: Set[tuple] | None = None, +) -> str: + rules = strip_paths if strip_paths is not None else DEFAULT_STRIP_PATHS + s = _strip(data, rules, tuple()) + return json.dumps(s, ensure_ascii=False, sort_keys=True, default=str) + + +def business_code(data: Any) -> str | None: + if isinstance(data, dict) and "code" in data: + c = data.get("code") + return str(c) if c is not None else None + return None + + +def same_shape(old_text: str, new_text: str) -> bool: + try: + a = parse_json_loose(old_text) + b = parse_json_loose(new_text) + except json.JSONDecodeError: + return False + if type(a) != type(b): + return False + if isinstance(a, dict) and isinstance(b, dict) and a.keys() == b.keys(): + return all(type(a[k]) is type(b[k]) for k in a) + if isinstance(a, list) and isinstance(b, list): + if len(a) == len(b) and len(a) == 0: + return True + return False + + +def describe_diff( + old_text: str, new_text: str, compare_norm: str = "deep" +) -> str: + try: + o = parse_json_loose(old_text) + n = parse_json_loose(new_text) + except json.JSONDecodeError as e: + return f"json parse: {e}" + if compare_norm == "code_only": + c1, c2 = business_code(o), business_code(n) + if c1 == c2: + return "" + return f"business code: old={c1!r} new={c2!r}" + no = normalize(o) + nn = normalize(n) + if no == nn: + return "" + if business_code(o) is not None and business_code(n) is not None and business_code(o) != business_code( + n + ): + return f"normalized JSON differs; first code: old={business_code(o)} new={business_code(n)}" + return "normalized JSON differs (see full bodies in test log)" diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/parity/health.py b/maven-cw-elevator-application/tools/elevator_api_parity/parity/health.py new file mode 100644 index 00000000..b41af8bd --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/parity/health.py @@ -0,0 +1,29 @@ +"""Probes that work for both Spring Boot 1.5/2.x with common actuator settings.""" + +import requests + +_CANDIDATES = ( + "/actuator/health", + "/actuator/health/", # noqa: PIE + "/health", + "/application/health", # very old +) + + +def probe_healthy(base_url: str, session: requests.Session, timeout: float = 2.0) -> tuple[str, bool, int]: + base = base_url.rstrip("/") + last_status = 0 + for path in _CANDIDATES: + try: + r = session.get(base + path, timeout=timeout, allow_redirects=True) + last_status = r.status_code + if r.status_code == 200 and _body_up(r.text): + return path, True, r.status_code + except requests.RequestException: + last_status = -1 + return _CANDIDATES[0], False, last_status + + +def _body_up(text: str) -> bool: + t = (text or "").lower() + return "up" in t or '"status":"ok"' in t or '"status": "ok"' in t diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/pytest.ini b/maven-cw-elevator-application/tools/elevator_api_parity/pytest.ini new file mode 100644 index 00000000..533e2901 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +minversion = 6.0 +addopts = -q --strict-markers +testpaths = tests +markers = + live: 需要两实例可访问 + unit: 纯逻辑单测 +pythonpath = . diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/report/__init__.py b/maven-cw-elevator-application/tools/elevator_api_parity/report/__init__.py new file mode 100644 index 00000000..263b6f05 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/report/__init__.py @@ -0,0 +1 @@ +"""Parity report generator.""" diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/report/generate_report.py b/maven-cw-elevator-application/tools/elevator_api_parity/report/generate_report.py new file mode 100644 index 00000000..6a432d67 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/report/generate_report.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import List + + +def timestamped_name(prefix: str) -> str: + return f"{prefix}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.md" + + +def write_file( + out_path: Path, + base_old: str, + base_new: str, + rows: List[dict], +) -> None: + lines = [ + f"# 电梯应用 API 新旧对拍报告\n", + f"- **时间**: {datetime.now().isoformat()}\n", + f"- **基线(旧) URL**: {base_old}\n", + f"- **新构建 URL**: {base_new}\n", + "\n## 用例\n\n", + "| 用例 | 方法+路径 | HTTP(旧) | HTTP(新) | 等值 | 说明 |\n", + "| ---- | -------- | -------- | -------- | ---- | ---- |\n", + ] + ok, bad = 0, 0 + for r in rows: + name = r.get("name", "") + pth = f"{r.get('method', '')} {r.get('path', '')}" + s1, s2 = r.get("old_status", ""), r.get("new_status", "") + eq = "Y" if r.get("match") else "N" + if r.get("match"): + ok += 1 + else: + bad += 1 + msg = (r.get("message", "") or "").replace("|", "\\|")[:200] + lines.append(f"| {name} | `{pth}` | {s1} | {s2} | {eq} | {msg} |\n") + lines.append( + f"\n## 汇总\n- 通过: {ok},不一致: {bad}。\n- **上线前请人工与联调/业务确认**。\n" + ) + out_path.write_text("".join(lines), encoding="utf-8") diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/requirements.txt b/maven-cw-elevator-application/tools/elevator_api_parity/requirements.txt new file mode 100644 index 00000000..3ff95231 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28.0 +pytest>=7.4.0 diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/tests/test_parity_endpoints.py b/maven-cw-elevator-application/tools/elevator_api_parity/tests/test_parity_endpoints.py new file mode 100644 index 00000000..560b5b31 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/tests/test_parity_endpoints.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from parity.catalog_loader import load as load_catalog +from parity.client import call_both + +_DIR = Path(__file__).resolve().parent.parent +_FIX = _DIR / "fixtures" + + +@pytest.mark.usefixtures("two_instances_ready") +@pytest.mark.live +def test_parity_from_catalog( + request, + base_old, + base_new, + session_http, +): + """按 api_catalog 双端对拍。compare_mode: deep | code_only | status_only""" + cat = load_catalog()["endpoints"] # type: ignore[operator] + for ep in cat: + name = ep["id"] + fixture = ep.get("fixture") + body: dict + if fixture: + body = json.loads((_FIX / fixture).read_text(encoding="utf-8")) + else: + body = {} + pr = call_both( + name=ep.get("name", name), + method=ep.get("method", "POST"), + path=ep["path"], + body=body, + base_old=base_old, + base_new=base_new, + session=session_http, + compare_mode=ep.get("compare_mode", "code_only"), + ) + row = { + "id": name, + "name": ep.get("name", name), + "method": pr.method, + "path": pr.path, + "old_status": pr.old_status, + "new_status": pr.new_status, + "match": pr.match, + "message": pr.message, + } + request.config._parity_rows.append(row) # type: ignore + # 在 code_only 下通常允许两边同一业务 code;若都非 200 也记录 + assert pr.match, ( + f"{name} mismatch: {pr.message}\n" + f"old_http={pr.old_status} new_http={pr.new_status}\n" + f"old_head={pr.old_text[:500]!r}\nnew_head={pr.new_text[:500]!r}" + ) diff --git a/maven-cw-elevator-application/tools/elevator_api_parity/tests/test_unit_compare.py b/maven-cw-elevator-application/tools/elevator_api_parity/tests/test_unit_compare.py new file mode 100644 index 00000000..34364959 --- /dev/null +++ b/maven-cw-elevator-application/tools/elevator_api_parity/tests/test_unit_compare.py @@ -0,0 +1,17 @@ +"""不依赖联调机:纯比较逻辑自测。""" + +from __future__ import annotations + +from parity import compare as C + + +def test_normalize_equal(): + a = {"b": 2, "a": 1, "c": {"z": 1, "y": 2}} + b = {"c": {"y": 2, "z": 1}, "a": 1, "b": 2} + assert C.normalize(a) == C.normalize(b) + + +def test_business_code_from_cloudwalk_result(): + j = '{"code":"0","data":{}}' + p = C.parse_json_loose(j) + assert C.business_code(p) == "0"