You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
9.7 KiB
244 lines
9.7 KiB
# -*- coding: utf-8 -*-
|
|
"""票 09 终验收:A4 客户管理 Bruno 文档联调就绪核验。
|
|
|
|
核验项(对 D:\\code\\crm-api-docs\\A4 客户管理 全量 .bru,排除 folder.bru):
|
|
V1 文件数与结构对账:磁盘文件 ↔ canonical-endpoint-response-mapping(117)+ 票 10-N3 补充(1)双向零缺零余,对账钥匙行一致
|
|
V2 「非真实返回」占位全仓 = 0
|
|
V3 四件套结构:meta / 请求块 / docs{ / 请求参数节 / 响应 data 结构节 / 响应示例节(二进制端点按票 05 口径豁免 data 结构与示例节)
|
|
V4 嵌套 DTO 展开:`List<非原始类型>` 字段表行后必须跟子表(field[]. 前缀行);其余泛型/对象行软性报告
|
|
V5 说明质量(票 06 口径):字段表行说明列非空且无断行残片(<3 格行)
|
|
V6 UTF-8 无 BOM
|
|
输出:控制台摘要 + verify-integration-ready.json(本目录)。
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
DOCS = r"D:\code\crm-api-docs\A4 客户管理"
|
|
MAPPING = r"D:\code\crm-backend-matt\.scratch\a4-doc-fields\canonical-endpoint-response-mapping.md"
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
OUT_JSON = os.path.join(HERE, "verify-integration-ready.json")
|
|
|
|
# 票 10-N3 新增端点(mapping 建于 a4-doc-fields 轮,彼时尚无关联项目端点)
|
|
SUPPLEMENT = {
|
|
"客户详情/关联项目/关联项目页签.bru": "GET /api/customer/project/page",
|
|
}
|
|
|
|
PRIMITIVES = {
|
|
"String", "Long", "Integer", "Boolean", "Double", "Float", "BigDecimal",
|
|
"Object", "LocalDate", "LocalDateTime", "LocalTime", "Date", "MultipartFile",
|
|
}
|
|
|
|
ROW_RE = re.compile(r"^\|(.+)$")
|
|
LIST_RE = re.compile(r"List<\s*([A-Z]\w*)")
|
|
GENERIC_RE = re.compile(r"<([A-Z]\w*)")
|
|
|
|
|
|
def iter_bru():
|
|
for root, dirs, files in os.walk(DOCS):
|
|
for fn in sorted(files):
|
|
if fn.endswith(".bru") and fn != "folder.bru":
|
|
p = os.path.join(root, fn)
|
|
rel = os.path.relpath(p, DOCS).replace("\\", "/")
|
|
yield rel, p
|
|
|
|
|
|
def read_file(p):
|
|
raw = open(p, "rb").read()
|
|
bom = raw[:3] == b"\xef\xbb\xbf"
|
|
return bom, raw.decode("utf-8-sig", errors="replace")
|
|
|
|
|
|
def parse_bru(txt):
|
|
"""返回 dict:meta/req/docs 各块有无 + docs 文本(去 2 空格缩进)+ 对账钥匙。"""
|
|
meta = re.search(r"^meta\s*\{", txt, re.M) is not None
|
|
req = re.search(r"^(get|post|put|delete)\s*\{", txt, re.M | re.I) is not None
|
|
m = re.search(r"^docs\s*\{", txt, re.M)
|
|
docs = ""
|
|
key = None
|
|
if m:
|
|
lines = []
|
|
for ln in txt[m.end():].splitlines():
|
|
if ln == "}": # 顶格 } = docs 块结束(JSON 花括号均在缩进内)
|
|
break
|
|
lines.append(ln[2:] if ln.startswith(" ") else ln)
|
|
docs = "\n".join(lines)
|
|
km = re.search(r"`(GET|POST|PUT|DELETE) (\S+)`", docs)
|
|
if km:
|
|
key = km.group(1) + " " + km.group(2)
|
|
return {"meta": meta, "req": req, "has_docs": m is not None, "docs": docs, "key": key}
|
|
|
|
|
|
def load_mapping():
|
|
"""canonical-endpoint-response-mapping.md → {relpath: (method_path, status)}"""
|
|
out = {}
|
|
with open(MAPPING, "r", encoding="utf-8") as f:
|
|
for ln in f:
|
|
m = re.match(r"^\|\s*`([^`]+\.bru)`\s*\|\s*`(GET|POST|PUT|DELETE) (\S+)`\s*\|\s*([^|]+)\|", ln)
|
|
if m:
|
|
out[m.group(1)] = (m.group(2) + " " + m.group(3), m.group(4).strip())
|
|
return out
|
|
|
|
|
|
def table_rows(docs):
|
|
"""docs 内所有字段表行(去缩进后以 | 开头),跳过表头分隔行。返回 [(idx, cells)]"""
|
|
rows = []
|
|
for i, ln in enumerate(docs.splitlines()):
|
|
s = ln.strip()
|
|
if not s.startswith("|"):
|
|
continue
|
|
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
if all(re.fullmatch(r"-{3,}", c) for c in cells if c):
|
|
continue
|
|
rows.append((i, cells))
|
|
return rows
|
|
|
|
|
|
def check_nested(docs, fname, hard_fail, soft):
|
|
"""List<非原始> 行须有 field[]. 子行;其余泛型/对象行软报告。"""
|
|
rows = table_rows(docs)
|
|
for idx, (i, cells) in enumerate(rows):
|
|
if len(cells) < 2:
|
|
continue
|
|
field = cells[0].replace("`", "").strip()
|
|
ftype = cells[1].replace("`", "").strip()
|
|
if not re.fullmatch(r"[A-Za-z_<>\[\], .\[\]]+", ftype):
|
|
continue
|
|
lm = LIST_RE.search(ftype)
|
|
if lm:
|
|
if lm.group(1) in PRIMITIVES:
|
|
continue
|
|
prefix = field + "[]."
|
|
found = False
|
|
prev_j = i
|
|
for j, cs in rows[idx + 1:]:
|
|
if j - prev_j > 2:
|
|
break # 中间断行/空行/标题 → 同表结束
|
|
prev_j = j
|
|
f0 = cs[0].replace("`", "").strip()
|
|
if f0.startswith(prefix) or f0.startswith(field + "."):
|
|
found = True
|
|
break
|
|
if f0[:1].isupper() and "." not in f0:
|
|
break # 新的顶层字段开始,同表结束
|
|
if not found:
|
|
hard_fail.append(f"{fname}: `{field}` 行类型 {ftype} 后无 {prefix} 子行")
|
|
elif GENERIC_RE.search(ftype) and ftype not in PRIMITIVES:
|
|
# Map<String,X> / BatchResult<X> 等非 List 泛型:软报告
|
|
prefix_exists = any(
|
|
cs[0].replace("`", "").strip().startswith(field + ("[]." if "List<" in ftype else "."))
|
|
for _, cs in rows[idx + 1:]
|
|
if cs[0].replace("`", "").strip().startswith(field)
|
|
)
|
|
if not prefix_exists:
|
|
soft.append(f"{fname}: `{field}` 非List泛型 {ftype} 无点路径子行(软报告)")
|
|
|
|
|
|
def main():
|
|
mapping = load_mapping()
|
|
expected = dict(mapping)
|
|
for k, v in SUPPLEMENT.items():
|
|
expected[k] = (v, "票10-N3 新增")
|
|
binary = {k for k, (_, st) in expected.items() if "二进制" in st}
|
|
|
|
files = {}
|
|
v2_hits, v3_fails, v5_fails, v6_fails = [], [], [], []
|
|
v4_hard, v4_soft = [], []
|
|
|
|
for rel, p in iter_bru():
|
|
bom, txt = read_file(p)
|
|
files[rel] = p
|
|
if bom:
|
|
v6_fails.append(rel)
|
|
if "非真实返回" in txt:
|
|
v2_hits.append(rel)
|
|
info = parse_bru(txt)
|
|
docs = info["docs"]
|
|
|
|
# V3 四件套
|
|
miss = []
|
|
if not info["meta"]:
|
|
miss.append("meta块")
|
|
if not info["req"]:
|
|
miss.append("请求块")
|
|
if not info["has_docs"]:
|
|
miss.append("docs块")
|
|
else:
|
|
if "## 请求参数" not in docs:
|
|
miss.append("请求参数节")
|
|
if rel not in binary:
|
|
if "## 响应 data 结构" not in docs:
|
|
miss.append("响应data结构节")
|
|
if "## 响应示例" not in docs:
|
|
miss.append("响应示例节")
|
|
if miss:
|
|
v3_fails.append(f"{rel}: 缺 {' / '.join(miss)}")
|
|
|
|
if docs:
|
|
# V4 嵌套展开
|
|
check_nested(docs, rel, v4_hard, v4_soft)
|
|
# V5 说明质量
|
|
for _, cells in table_rows(docs):
|
|
if len(cells) < 3:
|
|
v5_fails.append(f"{rel}: 断行残片行 `| {'|'.join(cells)}`")
|
|
continue
|
|
desc = cells[-1]
|
|
if not desc or desc == "-" or len(desc) < 2:
|
|
if cells[0].strip() in ("参数", "字段"): # 表头行
|
|
continue
|
|
v5_fails.append(f"{rel}: `{cells[0]}` 说明列为空")
|
|
|
|
# V1 结构对账
|
|
missing = sorted(set(expected) - set(files))
|
|
extra = sorted(set(files) - set(expected))
|
|
key_mismatch = []
|
|
for rel, p in sorted(files.items()):
|
|
if rel not in expected:
|
|
continue
|
|
info = parse_bru(read_file(p)[1])
|
|
if info["key"] != expected[rel][0]:
|
|
key_mismatch.append(f"{rel}: 钥匙 `{info['key']}` ≠ 期望 `{expected[rel][0]}`")
|
|
|
|
ok = lambda b: "PASS" if b else "FAIL"
|
|
print("=" * 72)
|
|
print("V1 结构对账 ", ok(not missing and not extra and not key_mismatch),
|
|
f"(磁盘 {len(files)} 文件 ↔ 期望 {len(expected)} = mapping {len(mapping)} + 票10补充 {len(SUPPLEMENT)})")
|
|
for x in missing:
|
|
print(" 缺文件:", x)
|
|
for x in extra:
|
|
print(" 多文件:", x)
|
|
for x in key_mismatch:
|
|
print(" 钥匙不符:", x)
|
|
print("V2 非真实返回=0 ", ok(not v2_hits), f"(命中 {len(v2_hits)})", v2_hits[:5])
|
|
print("V3 四件套结构 ", ok(not v3_fails), f"(缺节 {len(v3_fails)})")
|
|
for x in v3_fails[:15]:
|
|
print(" ", x)
|
|
print("V4 嵌套DTO展开 ", ok(not v4_hard), f"(硬失败 {len(v4_hard)},软报告 {len(v4_soft)})")
|
|
for x in v4_hard[:15]:
|
|
print(" ", x)
|
|
for x in v4_soft[:10]:
|
|
print(" [软]", x)
|
|
print("V5 说明质量 ", ok(not v5_fails), f"(问题行 {len(v5_fails)})")
|
|
for x in v5_fails[:15]:
|
|
print(" ", x)
|
|
print("V6 UTF-8 无 BOM ", ok(not v6_fails), f"(BOM {len(v6_fails)})", v6_fails[:5])
|
|
print("=" * 72)
|
|
all_pass = not (missing or extra or key_mismatch or v2_hits or v3_fails or v4_hard or v5_fails or v6_fails)
|
|
print("总体判定:", "ALL_PASS —— 联调就绪核验通过" if all_pass else "存在 FAIL 项,见上")
|
|
|
|
with open(OUT_JSON, "w", encoding="utf-8") as f:
|
|
json.dump({
|
|
"total_files": len(files), "expected": len(expected),
|
|
"missing": missing, "extra": extra, "key_mismatch": key_mismatch,
|
|
"v2_hits": v2_hits, "v3_fails": v3_fails,
|
|
"v4_hard": v4_hard, "v4_soft": v4_soft,
|
|
"v5_fails": v5_fails, "v6_fails": v6_fails,
|
|
"all_pass": all_pass,
|
|
}, f, ensure_ascii=False, indent=2)
|
|
print("明细 JSON:", OUT_JSON)
|
|
sys.exit(0 if all_pass else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|