feat(elevator): 对齐 V1 lib 的 Davinci/扫描/事件与部署配置

- davinci-manager-storage:FilePart 路径与基址按 V1 JAR(/portal/file、/part/*、GET /download)
- 启动类:扫描 cn.cloudwalk.serial 与 cn.cloudwalk.cwos.client.resource,补 UUIDSerial 与 ApplicationService
- deploy:v1/v2 application 中 cloudwalk.serial.enabled、Kafka 指向 192.168.3.12:9092;deploy/.gitignore 忽略日志
- cloudwalk-common-serial:补充 META-INF/spring.factories(Boot 自动配置)
- 电梯:Session 配置、Davinci Bean、Feign 包、MQTT/Visitor/Zone Feign;部署脚本与 API parity 工具更新
- 文档与根脚本若干;未纳入大体积 jar/zip 与 v1 CFR 对比目录

Made-with: Cursor

Former-commit-id: b76d142d13ebb5c0898de2d9d11bc583876829c2
This commit is contained in:
反编译工作区
2026-04-28 01:02:31 +08:00
parent be7a8e9d89
commit 418c7db202
61 changed files with 2967 additions and 461 deletions
@@ -2,3 +2,7 @@ __pycache__/
*.pyc
.pytest_cache/
report/parity-*.md
report/smoke-*.md
report/SUITE-*.md
report/*.json
.suite_run_marker
@@ -1,44 +1,92 @@
# elevator_api_parity — 新旧 JAR 接口对拍
# elevator_api_parity — V1/V2 接口冒烟与对拍
## 功能概览
| 能力 | 说明 |
|------|------|
| **单机冒烟** | 对单个 Base URL 遍历 `api_catalog.json` 中的接口(`include_in_smoke=true`),记录 HTTP 状态、耗时、业务 `code`、响应摘要 → `report/smoke-{label}-*.md` |
| **横向对拍** | 旧 JAR`--base-old`)与新 JAR`--base-new`)并行调用;仅 **`include_in_parity=true`** 的条目参与 **HTTP 状态 + 业务 code** 一致性断言 → `report/parity-*.md` |
| **套件总览** | 合并本次产生的冒烟×2 + 对拍 → `report/SUITE-*.md` |
接口清单与请求体见 **`api_catalog.json`**(支持 `fixture` 文件或内联 `body`)。
## 环境
- Python 3.8+
- 安装:`pip install -r requirements.txt`(在**本目录**下执行)
- Python 3.8+`pip install -r requirements.txt`
- 若遇全局 pytest 插件冲突:`export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1`
## 环境说明
## 启动两实例(示例)
本机若安装过 `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`(见该脚本内环境变量说明)
或手动启动两实例后:
同一套 `application.properties`(或外部配置),仅端口不同
```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
export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
CFG="file:/path/to/application.properties"
java -jar cw-elevator-application-V1.0.0.20211103.jar --server.port=18080 --spring.config.location=$CFG
java -jar cw-elevator-application-2.0.0.jar --server.port=18081 --spring.config.location=$CFG
```
## 配置
## 一键完整套件(推荐)
- `api_catalog.json`:对拍端点、方法、fixture 文件名、说明。
- `fixtures/*.json`:各接口请求体。
- 归一化忽略键可扩展 `parity/compare.py``DEFAULT_STRIP_PATHS`(如 `data.rows` 内动态字段,谨慎)。
**`maven-cw-elevator-application`** 目录:
## 报告
```bash
./scripts/run_full_elevator_api_suite.sh
```
- `report/parity-YYYYMMDD-HHMMSS.md`:实跑时由 `conftest` 的 session hook 与 `report/generate_report.py` 组合生成。
- JUnit(可选):`--junitxml=report/junit.xml`
环境变量(可选):
- `ELEVATOR_BASE_OLD` — 默认 `http://127.0.0.1:18080`V1
- `ELEVATOR_BASE_NEW` — 默认 `http://127.0.0.1:18081`V2
- `ELEVATOR_HEADER_BUSINESSID``ELEVATOR_HEADER_LOGINID``ELEVATOR_HEADER_AUTHORIZATION` 等 — 与现网一致时业务码更有可比性
## 分步执行
```bash
cd tools/elevator_api_parity
export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
# 仅逻辑单测
python3 -m pytest tests/test_unit_compare.py -q
# V1 单机冒烟
python3 -m pytest tests/test_smoke_catalog.py -m smoke \
--smoke-base=http://127.0.0.1:18080 --smoke-label=v1_legacy -q
# V2 单机冒烟
python3 -m pytest tests/test_smoke_catalog.py -m smoke \
--smoke-base=http://127.0.0.1:18081 --smoke-label=v2_build -q
# 双端对拍(双端 /actuator/health 等可达时执行,否则跳过)
python3 -m pytest tests/test_parity_endpoints.py -m live -q \
--base-old=http://127.0.0.1:18080 --base-new=http://127.0.0.1:18081
```
### 强制要求联调(失败即中断)
```bash
export ELEVATOR_PARITY_REQUIRE_LIVE=1 # 对拍
export ELEVATOR_SMOKE_REQUIRE=1 # 冒烟
```
## 仅对拍(不含冒烟)
```bash
./scripts/run_elevator_parity.sh
```
(脚本内会先 `mvn package`,再跑单测 + 对拍。)
## 报告位置
- `report/smoke-v1_legacy-*.md` + **同名 `.json`**(结构化结果,供套件矩阵消费)
- `report/smoke-v2_build-*.md` + **同名 `.json`**
- `report/parity-*.md` + **同名 `.json`**
- **`report/SUITE-*.md`**:始终包含 **第二节「全量接口清单」**(来源于 `api_catalog.json`);**第三节「测试结果矩阵」** 在有上述 JSON 时填入 V1/V2 HTTP、业务 code、对拍 Y/N;若本次因未起服务而 **skip**,矩阵中为 **—**(参见第三节说明)。
## 扩展接口
编辑 **`api_catalog.json`**
- `include_in_parity: false` — 只做冒烟,不参与 V1/V2 等值断言(避免依赖不一致导致误报)。
- `include_in_parity: true` — 纳入横向对拍(当前默认对 **访客/人员/规则分页/记录分页** 四个核心场景开启)。
@@ -1,21 +1,26 @@
{
"version": 1,
"version": 2,
"description": "电梯应用 HTTP 接口清单:冒烟与对拍共用。include_in_parity=false 的项仅做单机路由/业务响应探测,不参与 V1/V2 等值断言。",
"endpoints": [
{
"id": "person_add_visitor_min",
"name": "访客派梯-最小体(多依赖业务失败,仅比对 HTTP+业务code)",
"name": "访客派梯-最小体",
"method": "POST",
"path": "/elevator/person/add/visitor",
"fixture": "person_add_visitor_min.json",
"compare_mode": "code_only"
"compare_mode": "code_only",
"include_in_parity": true,
"include_in_smoke": true
},
{
"id": "person_detail",
"name": "人员详情-最小分页",
"name": "人员详情-分页",
"method": "POST",
"path": "/elevator/person/detail",
"fixture": "person_detail_min.json",
"compare_mode": "code_only"
"compare_mode": "code_only",
"include_in_parity": true,
"include_in_smoke": true
},
{
"id": "passrule_page",
@@ -23,7 +28,9 @@
"method": "POST",
"path": "/elevator/passRule/page",
"fixture": "passrule_page.json",
"compare_mode": "code_only"
"compare_mode": "code_only",
"include_in_parity": true,
"include_in_smoke": true
},
{
"id": "record_page",
@@ -31,7 +38,399 @@
"method": "POST",
"path": "/intelligent/acs/elevator/record/page",
"fixture": "record_page.json",
"compare_mode": "code_only"
"compare_mode": "code_only",
"include_in_parity": true,
"include_in_smoke": true
},
{
"id": "person_add",
"name": "人员-从现有人员添加",
"method": "POST",
"path": "/elevator/person/add",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "person_edit",
"name": "人员-编辑",
"method": "POST",
"path": "/elevator/person/edit",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "person_delete",
"name": "人员-删除",
"method": "POST",
"path": "/elevator/person/delete",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "person_page",
"name": "人员-分页",
"method": "POST",
"path": "/elevator/person/page",
"body": { "pageNo": 1, "pageSize": 1 },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "person_time_detail",
"name": "人员-时间详情",
"method": "POST",
"path": "/elevator/person/timeDetail",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "person_page_by_app",
"name": "人员-应用分页",
"method": "POST",
"path": "/elevator/person/pageByApp",
"body": { "pageNo": 1, "pageSize": 1 },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "passrule_floor",
"name": "通行规则-楼层列表",
"method": "POST",
"path": "/elevator/passRule/floor",
"body": { "pageNo": 1, "pageSize": 1, "zoneId": "" },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "passrule_add",
"name": "通行规则-新增",
"method": "POST",
"path": "/elevator/passRule/add",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "passrule_edit",
"name": "通行规则-修改",
"method": "POST",
"path": "/elevator/passRule/edit",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "passrule_delete",
"name": "通行规则-删除",
"method": "POST",
"path": "/elevator/passRule/delete",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "passrule_detail",
"name": "通行规则-详情",
"method": "POST",
"path": "/elevator/passRule/detail",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "passrule_image",
"name": "通行规则-按人像查楼层权限",
"method": "POST",
"path": "/elevator/passRule/image",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "passrule_image_list",
"name": "通行规则-批量人像",
"method": "POST",
"path": "/elevator/passRule/image/list",
"body": { "personList": [] },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "record_analyse_cycle",
"name": "记录-周期统计",
"method": "POST",
"path": "/intelligent/acs/elevator/record/analyse/cycle",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "record_analyse_count",
"name": "记录-次数统计",
"method": "POST",
"path": "/intelligent/acs/elevator/record/analyse/count",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "record_page_request",
"name": "记录-请求分页",
"method": "POST",
"path": "/intelligent/acs/elevator/record/page/request",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "record_device_list",
"name": "记录-设备列表",
"method": "POST",
"path": "/intelligent/acs/elevator/record/device/list",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "record_zone_tree",
"name": "记录-区域树",
"method": "POST",
"path": "/intelligent/acs/elevator/record/zone/tree",
"body": { "parentId": "", "businessId": "" },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "device_v2_39201",
"name": "设备网关-39201 设备列表",
"method": "POST",
"path": "/device/v2/39201",
"body": { "deviceName": "_api_probe", "currentPage": 1, "rowsOfPage": 10 },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "device_v2_39202",
"name": "设备网关-39202 区域电梯码",
"method": "POST",
"path": "/device/v2/39202",
"body": { "deviceName": "_api_probe", "currentPage": 1, "rowsOfPage": 10 },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "device_v2_39203",
"name": "设备网关-39203 添加记录",
"method": "POST",
"path": "/device/v2/39203",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "device_v2_39204",
"name": "设备网关-39204 密钥时间戳(已废弃)",
"method": "POST",
"path": "/device/v2/39204",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_add",
"name": "设备-新增",
"method": "POST",
"path": "/elevator/device/add",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_edit",
"name": "设备-编辑",
"method": "POST",
"path": "/elevator/device/edit",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_get_by_id",
"name": "设备-按ID查询",
"method": "POST",
"path": "/elevator/device/getById",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_delete",
"name": "设备-删除",
"method": "POST",
"path": "/elevator/device/delete",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_edit_code",
"name": "设备-改码",
"method": "POST",
"path": "/elevator/device/editCode",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_get",
"name": "设备-查询列表",
"method": "POST",
"path": "/elevator/device/get",
"body": { "deviceName": "_api_probe", "currentPage": 1, "rowsOfPage": 10 },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_export",
"name": "设备-导出",
"method": "POST",
"path": "/elevator/device/export",
"body": { "deviceName": "_api_probe", "currentPage": 1, "rowsOfPage": 10 },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_zone_tree_code",
"name": "设备-区域树编码",
"method": "POST",
"path": "/elevator/device/zone/treeCode",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "elevator_device_page",
"name": "设备-分页",
"method": "POST",
"path": "/elevator/device/devicePage",
"body": { "deviceName": "_api_probe", "currentPage": 1, "rowsOfPage": 10 },
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "restructure_unbind_floors",
"name": "改造-未绑定楼层",
"method": "POST",
"path": "/elevator/restructure/unbind/floors",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "restructure_floors",
"name": "改造-楼层列表",
"method": "POST",
"path": "/elevator/restructure/floors",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "restructure_condition",
"name": "改造-条件",
"method": "POST",
"path": "/elevator/restructure/condition",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "restructure_condition_labels",
"name": "改造-条件标签",
"method": "POST",
"path": "/elevator/restructure/condition/labels",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "restructure_binding",
"name": "改造-绑定",
"method": "POST",
"path": "/elevator/restructure/binding",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "restructure_binding_person",
"name": "改造-绑定人员",
"method": "POST",
"path": "/elevator/restructure/binding/person",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "restructure_task_progress",
"name": "改造-任务进度",
"method": "POST",
"path": "/elevator/restructure/task/progress",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
},
{
"id": "restructure_task_stop",
"name": "改造-停止任务",
"method": "POST",
"path": "/elevator/restructure/task/stop",
"body": {},
"compare_mode": "code_only",
"include_in_parity": false,
"include_in_smoke": true
}
]
}
@@ -1,18 +1,20 @@
from __future__ import annotations
import os
from datetime import datetime
from pathlib import Path
import pytest
import requests
from parity.client import can_reach_both, default_headers
from parity.client import can_reach_both, can_reach_one, default_headers
_DIR = Path(__file__).resolve().parent
def pytest_configure(config):
config._parity_rows = [] # type: ignore[attr-defined]
config._smoke_rows = [] # type: ignore[attr-defined]
def pytest_addoption(parser):
@@ -24,6 +26,14 @@ def pytest_addoption(parser):
"--base-new",
default=os.environ.get("ELEVATOR_BASE_NEW", "http://127.0.0.1:18081"),
)
parser.addoption(
"--smoke-base",
default=os.environ.get("ELEVATOR_SMOKE_BASE", "http://127.0.0.1:18080"),
)
parser.addoption(
"--smoke-label",
default=os.environ.get("ELEVATOR_SMOKE_LABEL", "v1_legacy"),
)
@pytest.fixture(scope="session")
@@ -36,6 +46,16 @@ def base_new(request):
return str(request.config.getoption("--base-new")).rstrip("/")
@pytest.fixture(scope="session")
def smoke_base(request):
return str(request.config.getoption("--smoke-base")).rstrip("/")
@pytest.fixture(scope="session")
def smoke_label(request):
return str(request.config.getoption("--smoke-label"))
@pytest.fixture(scope="session")
def session_http():
s = requests.Session()
@@ -55,27 +75,56 @@ def two_instances_ready(base_old, base_new, session_http, request):
return True
@pytest.fixture(scope="session")
def smoke_instance_ready(smoke_base, session_http, request):
ok, _ = can_reach_one(smoke_base, session_http)
require = os.environ.get("ELEVATOR_SMOKE_REQUIRE", "")
if not ok and not require:
pytest.skip(f"单机 {smoke_base} 健康检查不通过(跳过 smoke)")
if not ok and require:
pytest.fail(f"ELEVATOR_SMOKE_REQUIRE=1 且 {smoke_base} 不可达")
return True
def _write_smoke_report(config, srows: list, report_dir: Path) -> None:
from report import generate_smoke_report
label = str(config.getoption("--smoke-label", default="smoke"))
p2 = report_dir / f"smoke-{label}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.md"
generate_smoke_report.write_file(
p2,
str(config.getoption("--smoke-base", default="")),
label,
srows,
)
print(f"\n[smoke] 报告: {p2}")
def pytest_sessionfinish(session, exitstatus):
rows = getattr(session.config, "_parity_rows", None)
if not rows:
return
try:
from report import generate_report
import importlib
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}")
config = session.config
report_dir = _DIR / "report"
report_dir.mkdir(parents=True, exist_ok=True)
rows = getattr(config, "_parity_rows", None) or []
if rows:
try:
gen = importlib.import_module("report.generate_report")
p = report_dir / gen.timestamped_name("parity")
gen.write_file(
p,
str(config.getoption("--base-old", default="")),
str(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()
srows = getattr(config, "_smoke_rows", None) or []
if srows:
try:
_write_smoke_report(config, srows, report_dir)
except Exception as e:
print(f"\n[smoke] 报告未生成: {e}")
@@ -1,8 +1,42 @@
import json
from pathlib import Path
from typing import Any, Dict, Optional
_ROOT = Path(__file__).resolve().parent.parent
def load() -> dict:
return json.loads((_ROOT / "api_catalog.json").read_text(encoding="utf-8"))
def endpoint_body(ep: Dict[str, Any]) -> Dict[str, Any]:
"""Resolve request JSON: inline ``body`` wins, else load ``fixture`` file."""
if ep.get("body") is not None:
return dict(ep["body"])
fix = ep.get("fixture")
if fix:
p = _ROOT / "fixtures" / fix
return json.loads(p.read_text(encoding="utf-8"))
return {}
def iter_endpoints(catalog: dict, *, tag: Optional[str] = None) -> list[dict]:
"""If ``tag`` is set, only endpoints with ``tags`` containing it (or legacy entries with no tags = all)."""
out: list[dict] = []
for ep in catalog.get("endpoints", []):
tags = ep.get("tags") or []
if tag is None:
out.append(ep)
elif not tags:
out.append(ep)
elif tag in tags:
out.append(ep)
return out
def include_in_parity(ep: dict) -> bool:
return bool(ep.get("include_in_parity", True))
def include_in_smoke(ep: dict) -> bool:
return bool(ep.get("include_in_smoke", True))
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass
from typing import Any, Optional
@@ -124,6 +125,52 @@ def _safe_json(text: str) -> Any:
return None
def call_single(
name: str,
method: str,
path: str,
body: Any,
base_url: str,
session: requests.Session | None = None,
) -> dict[str, Any]:
"""One HTTP call for smoke coverage; returns a flat dict for reporting."""
s = session or requests.Session()
h = default_headers()
url = base_url.rstrip("/") + path
data = (
None
if body is None
else (body if isinstance(body, str) else json.dumps(body, ensure_ascii=False))
)
m = method.upper()
t0 = time.perf_counter()
if m == "GET":
r = s.get(url, headers=h, timeout=120)
else:
r = s.post(url, headers=h, data=data, timeout=120)
elapsed_ms = int((time.perf_counter() - t0) * 1000)
txt = r.text or ""
oj = _safe_json(txt)
bc = compare.business_code(oj) if oj is not None else None
head = txt[:400].replace("\n", " ")
return {
"name": name,
"method": m,
"path": path,
"http_status": r.status_code,
"elapsed_ms": elapsed_ms,
"business_code": bc,
"response_head": head,
"reachable": True,
}
def can_reach_one(base_url: str, s: requests.Session | None = None) -> tuple[bool, str]:
s = s or requests.Session()
_, ok, _ = probe_healthy(base_url, s)
return ok, base_url
def can_reach_both(
base_old: str, base_new: str, s: requests.Session | None = None
) -> tuple[bool, str]:
@@ -4,5 +4,6 @@ addopts = -q --strict-markers
testpaths = tests
markers =
live: 需要两实例可访问
smoke: 单机全量 HTTP 探测
unit: 纯逻辑单测
pythonpath = .
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import List
@@ -40,3 +41,16 @@ def write_file(
f"\n## 汇总\n- 通过: {ok},不一致: {bad}\n- **上线前请人工与联调/业务确认**。\n"
)
out_path.write_text("".join(lines), encoding="utf-8")
payload = {
"meta": {
"generated_at": datetime.now().isoformat(),
"base_old": base_old,
"base_new": base_new,
"markdown": str(out_path.resolve()),
},
"rows": rows,
"summary": {"match_ok": ok, "match_bad": bad},
}
json_path = out_path.with_suffix(".json")
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -0,0 +1,53 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import List
def write_file(
out_path: Path,
base_url: str,
label: str,
rows: List[dict],
) -> None:
lines = [
f"# 电梯应用 API 单机冒烟报告 — {label}\n",
f"- **时间**: {datetime.now().isoformat()}\n",
f"- **Base URL**: {base_url}\n",
"\n## 接口探测\n\n",
"| 用例 | 方法+路径 | HTTP | ms | 业务code | 响应摘要 |\n",
"| ---- | -------- | ---- | -- | -------- | -------- |\n",
]
ok_http = 0
for r in rows:
name = r.get("name", "")
pth = f"{r.get('method', '')} {r.get('path', '')}"
hs = r.get("http_status", "")
ms = r.get("elapsed_ms", "")
bc = r.get("business_code", "") or ""
head = (r.get("response_head", "") or "").replace("|", "\\|")[:180]
if isinstance(hs, int) and 200 <= hs < 300:
ok_http += 1
lines.append(f"| {name} | `{pth}` | {hs} | {ms} | {bc} | {head} |\n")
lines.append(
f"\n## 汇总\n"
f"- 用例数: {len(rows)}HTTP 2xx 数量: {ok_http}\n"
f"- 业务失败(非 0 code)仍可能为**预期**(缺数据/缺 token);本报告仅证明路由可达且返回 Cloudwalk 风格 JSON。\n"
)
out_path.write_text("".join(lines), encoding="utf-8")
payload = {
"meta": {
"generated_at": datetime.now().isoformat(),
"base_url": base_url,
"label": label,
"markdown": str(out_path.resolve()),
},
"rows": rows,
"summary": {"total": len(rows), "http_2xx": ok_http},
}
out_path.with_suffix(".json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""合并电梯 API 测试套件总览:全量清单(catalog)+ 测试结果矩阵(JSON)+ 可选附录(子报告 MD)。"""
from __future__ import annotations
import argparse
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
def _tool_root() -> Path:
return Path(__file__).resolve().parent.parent
def _load_catalog(path: Path) -> List[dict]:
data = json.loads(path.read_text(encoding="utf-8"))
return list(data.get("endpoints") or [])
def _safe_json_load(p: Optional[Path]) -> Optional[dict]:
if not p or not p.is_file():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def _rows_by_id(rows: Any) -> Dict[str, dict]:
out: Dict[str, dict] = {}
if not isinstance(rows, list):
return out
for r in rows:
if not isinstance(r, dict):
continue
eid = r.get("id")
if not eid:
# 对拍行可能在 id 字段,否则用 path 不可靠
continue
out[str(eid)] = r
return out
def _parity_by_id(rows: Any) -> Dict[str, dict]:
out: Dict[str, dict] = {}
if not isinstance(rows, list):
return out
for r in rows:
if isinstance(r, dict) and r.get("id"):
out[str(r["id"])] = r
return out
def _resolve_json_arg(md_or_json: Optional[str]) -> Optional[Path]:
if not md_or_json:
return None
p = Path(md_or_json)
if p.suffix.lower() == ".json" and p.is_file():
return p
if p.suffix.lower() == ".md":
cand = p.with_suffix(".json")
if cand.is_file():
return cand
return None
def _markdown_table_catalog(endpoints: List[dict]) -> str:
lines = [
"| ID | 名称 | 方法 | Path | 冒烟 | 横向对拍 |\n",
"| ---- | ---- | ---- | ---- | ---- | -------- |\n",
]
for ep in endpoints:
eid = ep.get("id", "")
name = (ep.get("name") or "").replace("|", "\\|")
method = ep.get("method") or "POST"
path = (ep.get("path") or "").replace("|", "\\|")
sm = "" if ep.get("include_in_smoke", True) else ""
py = "" if ep.get("include_in_parity", True) else ""
lines.append(f"| `{eid}` | {name} | {method} | `{path}` | {sm} | {py} |\n")
return "".join(lines)
def _markdown_matrix(
endpoints: List[dict],
smoke_v1: Optional[dict],
smoke_v2: Optional[dict],
parity: Optional[dict],
) -> str:
m1 = _rows_by_id((smoke_v1 or {}).get("rows"))
m2 = _rows_by_id((smoke_v2 or {}).get("rows"))
mp = _parity_by_id((parity or {}).get("rows"))
meta_v1 = (smoke_v1 or {}).get("meta") or {}
meta_v2 = (smoke_v2 or {}).get("meta") or {}
meta_pr = (parity or {}).get("meta") or {}
head = ""
head += (
f"- **V1 冒烟数据来源**: {meta_v1.get('markdown') or meta_v1.get('base_url') or '(无 JSON,未执行或已跳过)'}\n"
)
head += (
f"- **V2 冒烟数据来源**: {meta_v2.get('markdown') or meta_v2.get('base_url') or '(无)'}\n"
)
head += f"- **对拍数据来源**: {meta_pr.get('markdown') or '(无)'}\n\n"
lines = [
head,
"### 测试结果矩阵(按 catalog `id` 对齐)\n\n",
"| catalog id | V1 HTTP | V1 code | V2 HTTP | V2 code | 对拍一致(仅参与对拍的条目) | 备注 |\n",
"| ---------- | ------- | ------- | ------- | ------- | ---------------------------- | ---- |\n",
]
for ep in endpoints:
eid = str(ep.get("id", ""))
include_p = ep.get("include_in_parity", True)
v1 = m1.get(eid)
v2 = m2.get(eid)
pr_row = mp.get(eid) if include_p else None
def cell_smoke(row: Optional[dict]) -> tuple[str, str]:
if not row:
return "", ""
return str(row.get("http_status", "")), str(row.get("business_code") or "")
h1, c1 = cell_smoke(v1)
h2, c2 = cell_smoke(v2)
if not include_p:
par_c = "(不参与)"
elif not parity:
par_c = "—(未执行)"
elif pr_row:
par_c = "**Y**" if pr_row.get("match") else "**N**"
if not pr_row.get("match"):
par_c += " " + (pr_row.get("message") or "")[:60].replace("|", "\\|")
else:
par_c = "—(本次对拍清单无此项)"
remark = ""
if v1 is None and v2 is None:
remark = "冒烟未执行或无该 id 结果"
lines.append(
f"| `{eid}` | {h1} | {c1} | {h2} | {c2} | {par_c} | {remark} |\n"
)
lines.append(
"\n**说明**`code` 为 CloudwalkResult 顶层业务码;HTTP 为传输层状态。"
"对拍列为 **Y** 表示旧/新 HTTP 状态一致且业务 code 一致(`code_only` 模式)。\n"
)
return "".join(lines)
def main() -> None:
ap = argparse.ArgumentParser(description="Generate SUITE markdown with catalog + matrix")
ap.add_argument("--out", required=True, help="Output SUITE-*.md path")
ap.add_argument("--catalog", help="api_catalog.json path")
ap.add_argument("--smoke-v1", help="smoke-v1_*.md 或同名 .json")
ap.add_argument("--smoke-v2", help="smoke-v2_*.md 或同名 .json")
ap.add_argument("--parity", help="parity-*.md 或同名 .json")
ap.add_argument(
"--embed-full",
action="store_true",
help="附录中嵌入子报告 Markdown 全文(较长)",
)
args = ap.parse_args()
root = _tool_root()
catalog_path = Path(args.catalog or (root / "api_catalog.json"))
endpoints = _load_catalog(catalog_path)
js_v1 = _resolve_json_arg(args.smoke_v1)
js_v2 = _resolve_json_arg(args.smoke_v2)
js_pr = _resolve_json_arg(args.parity)
doc_v1 = _safe_json_load(js_v1)
doc_v2 = _safe_json_load(js_v2)
doc_pr = _safe_json_load(js_pr)
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
lines: List[str] = [
"# 电梯应用 API 测试套件总览\n\n",
f"- **生成时间**: {datetime.now().isoformat()}\n",
f"- **清单来源**: `{catalog_path.resolve()}`(共 **{len(endpoints)}** 条接口定义)\n\n",
"## 1. 说明\n\n",
"- **全量清单**:第二节,来自 `api_catalog.json`,含是否参与冒烟/对拍。\n",
"- **测试结果矩阵**:第三节,与本次运行生成的 **JSON** 侧车文件对齐(与 `.md` 同名的 `.json`)。"
"若应用未启动导致 pytest **skip**,则无 JSON,矩阵中表现为 **—**。\n",
"- **横向对拍**:仅 `include_in_parity=true` 的条目会写入对拍 JSON 并参与对比。\n\n",
"## 2. 全量接口测试清单(catalog)\n\n",
_markdown_table_catalog(endpoints),
"\n",
"## 3. 测试结果矩阵\n\n",
_markdown_matrix(endpoints, doc_v1, doc_v2, doc_pr),
]
sec = 4
if args.embed_full:
for title, path_s in (
("V1 单机冒烟 Markdown", args.smoke_v1),
("V2 单机冒烟 Markdown", args.smoke_v2),
("横向对拍 Markdown", args.parity),
):
if not path_s:
continue
pp = Path(path_s)
if pp.suffix.lower() != ".md":
pp = pp.with_suffix(".md") if pp.with_suffix(".md").is_file() else pp
if not pp.is_file():
continue
lines.append(f"## {sec}. {title}\n\n")
lines.append(f"源文件: `{pp.resolve()}`\n\n---\n\n")
lines.append(pp.read_text(encoding="utf-8"))
lines.append("\n\n")
sec += 1
lines.append(
f"\n## {sec}. 原始报告路径(便于回放)\n\n"
f"- V1 冒烟: `{args.smoke_v1 or '(未生成)'}`\n"
f"- V2 冒烟: `{args.smoke_v2 or '(未生成)'}`\n"
f"- 对拍: `{args.parity or '(未生成)'}`\n"
f"- 同名 **`.json`** 与 `.md` 一并生成时可自动填充第三节矩阵。\n"
)
out.write_text("".join(lines), encoding="utf-8")
print(out.resolve())
if __name__ == "__main__":
main()
@@ -1,15 +1,11 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from parity.catalog_loader import endpoint_body as cb_body
from parity.catalog_loader import include_in_parity as cb_parity
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
@@ -19,16 +15,13 @@ def test_parity_from_catalog(
base_new,
session_http,
):
"""按 api_catalog 双端对拍。compare_mode: deep | code_only | status_only"""
"""按 api_catalog 双端对拍(仅 ``include_in_parity`` 为 true 的条目)。compare_mode: deep | code_only | status_only"""
cat = load_catalog()["endpoints"] # type: ignore[operator]
for ep in cat:
if not cb_parity(ep):
continue
name = ep["id"]
fixture = ep.get("fixture")
body: dict
if fixture:
body = json.loads((_FIX / fixture).read_text(encoding="utf-8"))
else:
body = {}
body = cb_body(ep)
pr = call_both(
name=ep.get("name", name),
method=ep.get("method", "POST"),
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from parity.catalog_loader import endpoint_body as cb_body
from parity.catalog_loader import include_in_smoke as cb_smoke
from parity.catalog_loader import load as load_catalog
from parity.client import call_single
"""单机按 catalog 逐接口 POST/GET,写入 _smoke_rowssessionfinish 落盘)。"""
@pytest.mark.usefixtures("smoke_instance_ready")
@pytest.mark.smoke
def test_smoke_from_catalog(request, smoke_base, smoke_label, session_http):
cat = load_catalog()["endpoints"]
for ep in cat:
if not cb_smoke(ep):
continue
name = ep["id"]
body = cb_body(ep)
row = call_single(
name=ep.get("name", name),
method=ep.get("method", "POST"),
path=ep["path"],
body=body,
base_url=smoke_base,
session=session_http,
)
row["id"] = name
row["label"] = smoke_label
request.config._smoke_rows.append(row) # type: ignore