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.
332 lines
15 KiB
332 lines
15 KiB
|
2 days ago
|
# -*- coding: utf-8 -*-
|
||
|
|
"""A4 Bruno 响应字段表补全工具(v2:修复 static 污染 / 类级 @Schema 错配 / record 不消费 @Schema)。
|
||
|
|
|
||
|
|
仅替换 docs 中"响应 data 结构"区块;已有 E2E 响应示例保留。
|
||
|
|
返回类型取 Controller 实际声明,字段说明从源码 @Schema(description=...) 静态解析;
|
||
|
|
拿不到 @Schema 的字段说明留空,便于诊断定位待补。
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
from collections import Counter
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
ROOT = Path(r"d:\code\crm-backend-matt")
|
||
|
|
DOCS = Path(r"D:\code\crm-api-docs\A4 客户管理")
|
||
|
|
REPORT = ROOT / ".scratch" / "a4-doc-fields" / "canonical-endpoint-response-mapping.md"
|
||
|
|
SKIP_FIELDS = {"deleted", "creatorId", "updaterId", "createTime", "updateTime", "serialVersionUID"}
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class Endpoint:
|
||
|
|
method: str
|
||
|
|
path: str
|
||
|
|
return_type: str
|
||
|
|
source: Path
|
||
|
|
|
||
|
|
|
||
|
|
def clean_type(value: str) -> str:
|
||
|
|
value = re.sub(r"\s+", "", value)
|
||
|
|
return value.replace("?extends", "").replace("?super", "")
|
||
|
|
|
||
|
|
|
||
|
|
def split_generic(value: str) -> tuple[str, str | None]:
|
||
|
|
value = clean_type(value)
|
||
|
|
if "<" not in value:
|
||
|
|
return value, None
|
||
|
|
return value[: value.index("<")], value[value.index("<") + 1 : value.rfind(">")]
|
||
|
|
|
||
|
|
|
||
|
|
def split_top_commas(s: str) -> list[str]:
|
||
|
|
"""按顶层逗号切分,忽略括号内的逗号。"""
|
||
|
|
parts, depth, cur = [], 0, []
|
||
|
|
for ch in s:
|
||
|
|
if ch in "([{":
|
||
|
|
depth += 1; cur.append(ch)
|
||
|
|
elif ch in ")]}":
|
||
|
|
depth -= 1; cur.append(ch)
|
||
|
|
elif ch == "," and depth == 0:
|
||
|
|
parts.append("".join(cur)); cur = []
|
||
|
|
else:
|
||
|
|
cur.append(ch)
|
||
|
|
if cur:
|
||
|
|
parts.append("".join(cur))
|
||
|
|
return parts
|
||
|
|
|
||
|
|
|
||
|
|
def java_sources() -> list[Path]:
|
||
|
|
modules = ["crm-customer", "crm-base", "crm-preference", "crm-opportunity",
|
||
|
|
"crm-lead", "crm-dict", "crm-file", "crm-auth", "crm-log", "crm-rule", "crm-project"]
|
||
|
|
return [p for name in modules for p in (ROOT / name).rglob("*.java")]
|
||
|
|
|
||
|
|
|
||
|
|
def endpoint_index(sources: list[Path]) -> dict[tuple[str, str], Endpoint]:
|
||
|
|
result: dict[tuple[str, str], Endpoint] = {}
|
||
|
|
class_re = re.compile(r'@RequestMapping\s*\(\s*"([^"]*)"\s*\)')
|
||
|
|
map_re = re.compile(
|
||
|
|
r'@(Get|Post|Put|Delete|Request)Mapping\s*\(([^)]*)\)\s*'
|
||
|
|
r'public\s+([\w<>?, .\[\]]+)\s+\w+\s*\(', re.S)
|
||
|
|
for source in sources:
|
||
|
|
text = source.read_text(encoding="utf-8-sig")
|
||
|
|
base_match = class_re.search(text)
|
||
|
|
if not base_match:
|
||
|
|
continue
|
||
|
|
base = base_match.group(1)
|
||
|
|
for match in map_re.finditer(text):
|
||
|
|
verb, args, ret = match.groups()
|
||
|
|
path_match = re.search(r'"([^"]*)"', args)
|
||
|
|
suffix = path_match.group(1) if path_match else ""
|
||
|
|
methods = [verb.upper()] if verb != "Request" else re.findall(r"RequestMethod\.([A-Z]+)", args)
|
||
|
|
for method in methods:
|
||
|
|
result[(method, base + suffix)] = Endpoint(method, base + suffix, clean_type(ret), source)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def class_index(sources: list[Path]) -> dict[str, Path]:
|
||
|
|
found: dict[str, Path] = {}
|
||
|
|
for source in sources:
|
||
|
|
text = source.read_text(encoding="utf-8-sig")
|
||
|
|
for match in re.finditer(r'\b(?:class|interface|record|enum)\s+(\w+)', text):
|
||
|
|
found.setdefault(match.group(1), source)
|
||
|
|
return found
|
||
|
|
|
||
|
|
|
||
|
|
def parse_record(text: str, outer: str) -> list[tuple[str, str, str]] | None:
|
||
|
|
"""解析 record 参数列表,消费每个字段紧邻的 @Schema(description)。"""
|
||
|
|
m = re.search(r'\brecord\s+' + re.escape(outer) + r'\s*\((.*?)\)\s*(?:\{|implements|extends|;|\Z)', text, re.S)
|
||
|
|
if not m:
|
||
|
|
return None
|
||
|
|
rows: list[tuple[str, str, str]] = []
|
||
|
|
for comp in split_top_commas(m.group(1)):
|
||
|
|
comp = comp.strip()
|
||
|
|
if not comp:
|
||
|
|
continue
|
||
|
|
dm = re.search(r'@Schema\([^)]*description\s*=\s*"([^"]+)"', comp)
|
||
|
|
decl = re.sub(r'@\w+\([^)]*\)', '', comp).strip().rstrip(',')
|
||
|
|
fm = re.match(r'([\w$][\w<>?,.\[\]$]*(?:\s*<[^>]*>)*)\s+(\w+)\s*$', decl)
|
||
|
|
if not fm:
|
||
|
|
continue
|
||
|
|
ftype, name = fm.group(1).strip(), fm.group(2)
|
||
|
|
if re.search(r'@Schema\([^)]*hidden\s*=\s*true', comp):
|
||
|
|
continue
|
||
|
|
rows.append((name, clean_type(ftype), dm.group(1) if dm else ""))
|
||
|
|
return rows
|
||
|
|
|
||
|
|
|
||
|
|
def parse_class_fields(text: str) -> list[tuple[str, str, str]]:
|
||
|
|
"""逐行状态机:@Schema(description) 紧贴字段才消费;static/transient/hidden 跳过;方法/类型声明清空缓冲。"""
|
||
|
|
rows: list[tuple[str, str, str]] = []
|
||
|
|
buf_desc: str | None = None
|
||
|
|
buf_hidden = False
|
||
|
|
lines = text.split('\n')
|
||
|
|
i, n = 0, len(lines)
|
||
|
|
field_re = re.compile(
|
||
|
|
r'(?:public|private|protected)\s+(?P<mods>(?:(?:static|final|transient|volatile)\s+)*)?'
|
||
|
|
r'(?P<type>[\w$][\w<>?,.\[\]$]*(?:\s*<[^>]*>)*)\s+(?P<name>\w+)\s*(?:=[^;]*)?;')
|
||
|
|
method_re = re.compile(r'(?:public|private|protected|static|final|abstract|default)?\s*[\w<>\[\]?,.\$]+\s+\w+\s*\(')
|
||
|
|
type_re = re.compile(r'(?:public|abstract|final)\s+(?:class|interface|record|enum)\b')
|
||
|
|
while i < n:
|
||
|
|
s = lines[i].strip()
|
||
|
|
if not s:
|
||
|
|
i += 1; continue
|
||
|
|
if s.startswith('@Schema(') and s.count(')') <= s.count('('):
|
||
|
|
acc = s
|
||
|
|
while i + 1 < n and acc.count(')') < acc.count('('):
|
||
|
|
i += 1; acc += ' ' + lines[i].strip()
|
||
|
|
dm = re.search(r'description\s*=\s*"([^"]+)"', acc)
|
||
|
|
buf_desc = dm.group(1) if dm else buf_desc
|
||
|
|
buf_hidden = bool(re.search(r'hidden\s*=\s*true', acc))
|
||
|
|
i += 1; continue
|
||
|
|
dm = re.search(r'@Schema\([^)]*description\s*=\s*"([^"]+)"', s)
|
||
|
|
if dm:
|
||
|
|
buf_desc = dm.group(1)
|
||
|
|
buf_hidden = bool(re.search(r'hidden\s*=\s*true', s))
|
||
|
|
i += 1; continue
|
||
|
|
fm = field_re.match(s)
|
||
|
|
if fm:
|
||
|
|
mods = fm.group("mods") or ""
|
||
|
|
name = fm.group("name")
|
||
|
|
if "static" in mods:
|
||
|
|
buf_desc = None; buf_hidden = False; i += 1; continue
|
||
|
|
if buf_hidden:
|
||
|
|
buf_desc = None; buf_hidden = False; i += 1; continue
|
||
|
|
if name not in SKIP_FIELDS:
|
||
|
|
rows.append((name, clean_type(fm.group("type")), buf_desc or ""))
|
||
|
|
buf_desc = None; buf_hidden = False; i += 1; continue
|
||
|
|
if method_re.match(s) or type_re.match(s):
|
||
|
|
buf_desc = None; buf_hidden = False
|
||
|
|
i += 1
|
||
|
|
return rows
|
||
|
|
|
||
|
|
|
||
|
|
def fields_for(type_name: str, classes: dict[str, Path], seen: set[str] | None = None) -> list[tuple[str, str, str]]:
|
||
|
|
seen = seen or set()
|
||
|
|
outer, _ = split_generic(type_name)
|
||
|
|
outer = outer.split(".")[-1]
|
||
|
|
if outer in seen or outer not in classes:
|
||
|
|
return []
|
||
|
|
seen.add(outer)
|
||
|
|
text = classes[outer].read_text(encoding="utf-8-sig")
|
||
|
|
rec = parse_record(text, outer)
|
||
|
|
rows = rec if rec is not None else parse_class_fields(text)
|
||
|
|
parent = re.search(r'\bclass\s+\w+\s+extends\s+([\w.<>]+)', text)
|
||
|
|
if parent:
|
||
|
|
rows = fields_for(parent.group(1), classes, seen) + rows
|
||
|
|
return rows
|
||
|
|
|
||
|
|
|
||
|
|
def unwrap(return_type: str) -> str:
|
||
|
|
outer, inner = split_generic(return_type)
|
||
|
|
if outer in {"Result", "ResponseEntity"} and inner:
|
||
|
|
return unwrap(inner)
|
||
|
|
return return_type
|
||
|
|
|
||
|
|
|
||
|
|
def type_shape(value: str) -> tuple[str, str | None]:
|
||
|
|
value = unwrap(value)
|
||
|
|
outer, inner = split_generic(value)
|
||
|
|
if outer == "PageResult":
|
||
|
|
return "page", inner
|
||
|
|
if outer in {"List", "Collection", "Set"}:
|
||
|
|
return "list", inner
|
||
|
|
if outer in {"Void", "void"}:
|
||
|
|
return "void", None
|
||
|
|
if outer in {"String", "Long", "Integer", "Boolean", "Double", "BigDecimal"}:
|
||
|
|
return "scalar", outer
|
||
|
|
if outer == "byte[]":
|
||
|
|
return "binary", None
|
||
|
|
return "object", value
|
||
|
|
|
||
|
|
|
||
|
|
def scalar_value(type_name: str, field: str = "") -> object:
|
||
|
|
if type_name in {"Long", "Integer", "Short", "Double", "BigDecimal", "Float"}:
|
||
|
|
return 1
|
||
|
|
if type_name in {"Boolean", "boolean"}:
|
||
|
|
return False
|
||
|
|
if "Time" in type_name or "Date" in type_name:
|
||
|
|
return "2026-01-01 00:00:00"
|
||
|
|
return f"示例{field}" if field else "示例文本"
|
||
|
|
|
||
|
|
|
||
|
|
def data_structure(return_type: str, classes: dict[str, Path]) -> tuple[list[tuple[str, str, str]], object, str]:
|
||
|
|
shape, item = type_shape(return_type)
|
||
|
|
if shape == "void":
|
||
|
|
return [("data", "null", "操作成功,无业务返回数据")], None, "空响应"
|
||
|
|
if shape == "binary":
|
||
|
|
return [("data", "binary", "二进制文件流;成功时不使用 JSON 信封")], "二进制文件流", "二进制"
|
||
|
|
if shape == "scalar":
|
||
|
|
return [("data", item or "string", "标量返回值,含义见接口说明")], scalar_value(item or "String"), "标量"
|
||
|
|
prefix = "content[]." if shape == "page" else "[]." if shape == "list" else ""
|
||
|
|
fields = fields_for(item or "", classes)
|
||
|
|
if not fields:
|
||
|
|
return [("data", item or "object", "返回对象;源码未找到可静态展开的字段")], {}, "不可展开对象"
|
||
|
|
rows = [(prefix + name, field_type, desc) for name, field_type, desc in fields]
|
||
|
|
obj = {name: scalar_value(field_type, name) for name, field_type, _ in fields}
|
||
|
|
if shape == "page":
|
||
|
|
rows = [("content[]", "array", "分页内容,元素结构如下")] + rows + [
|
||
|
|
("total", "Long", "总条数"), ("size", "Long", "每页条数"),
|
||
|
|
("current", "Long", "当前页"), ("pages", "Long", "总页数"), ("empty", "Boolean", "是否为空")]
|
||
|
|
return rows, {"content": [obj], "total": 1, "size": 10, "current": 1, "pages": 1, "empty": False}, "分页"
|
||
|
|
if shape == "list":
|
||
|
|
return rows, [obj], "列表"
|
||
|
|
return rows, obj, "对象"
|
||
|
|
|
||
|
|
|
||
|
|
def endpoint_key(text: str) -> tuple[str, str] | None:
|
||
|
|
match = re.search(r'^\s*`(GET|POST|PUT|DELETE)\s+([^`?]+)', text, re.M)
|
||
|
|
return (match.group(1), match.group(2)) if match else None
|
||
|
|
|
||
|
|
|
||
|
|
def has_complete_structure(text: str) -> bool:
|
||
|
|
return bool(re.search(r'## 响应 data 结构\s*\n\s*\|\s*字段\s*\|\s*类型\s*\|\s*说明\s*\|', text)) and "示例数据" in text
|
||
|
|
|
||
|
|
|
||
|
|
def section(rows: list[tuple[str, str, str]], data: object, keep_sample_hint: bool) -> str:
|
||
|
|
table = [" ## 响应 data 结构", "", " | 字段 | 类型 | 说明 |", " | --- | --- | --- |"]
|
||
|
|
table.extend(f" | {name} | {field_type} | {desc} |" for name, field_type, desc in rows)
|
||
|
|
if keep_sample_hint:
|
||
|
|
sample = ["", " ## 响应示例", "", " // 示例数据,字段结构以上表为准,非真实返回", " ```json"]
|
||
|
|
payload = {"code": 0, "success": True, "message": "success", "data": data}
|
||
|
|
sample.extend(" " + line for line in json.dumps(payload, ensure_ascii=False, indent=2).splitlines())
|
||
|
|
sample.extend([" ```"])
|
||
|
|
return "\n".join(table + sample)
|
||
|
|
return "\n".join(table)
|
||
|
|
|
||
|
|
|
||
|
|
def patch(text: str, replacement: str) -> str:
|
||
|
|
table_only = "\n".join(replacement.split("\n\n ## 响应示例", 1)[0].splitlines())
|
||
|
|
if " ## 响应示例" in text:
|
||
|
|
pattern = re.compile(r' ## 响应 data 结构[\s\S]*?(?=\n ## 响应示例)', re.M)
|
||
|
|
new_text, count = pattern.subn(table_only, text, count=1)
|
||
|
|
else:
|
||
|
|
full = replacement if " ## 响应示例" in replacement else replacement
|
||
|
|
pattern = re.compile(r' ## 响应 data 结构[\s\S]*?(?=\n ## 错误码|\n\}\s*$|\Z)', re.M)
|
||
|
|
new_text, count = pattern.subn(full, text, count=1)
|
||
|
|
if count != 1:
|
||
|
|
raise ValueError("未找到唯一的响应区块")
|
||
|
|
return new_text
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
parser = argparse.ArgumentParser()
|
||
|
|
parser.add_argument("--apply", action="store_true")
|
||
|
|
parser.add_argument("--force", action="store_true", help="强制重写所有端点的字段表(忽略 has_complete_structure)")
|
||
|
|
args = parser.parse_args()
|
||
|
|
sources = java_sources()
|
||
|
|
endpoints = endpoint_index(sources)
|
||
|
|
classes = class_index(sources)
|
||
|
|
report_rows: list[str] = []
|
||
|
|
counts: Counter[str] = Counter()
|
||
|
|
changed = 0
|
||
|
|
missing_desc: list[str] = []
|
||
|
|
for file in sorted(DOCS.rglob("*.bru")):
|
||
|
|
if file.name == "folder.bru":
|
||
|
|
continue
|
||
|
|
text = file.read_text(encoding="utf-8-sig")
|
||
|
|
key = endpoint_key(text)
|
||
|
|
relative = file.relative_to(DOCS).as_posix()
|
||
|
|
if not key:
|
||
|
|
counts["无对账钥匙"] += 1
|
||
|
|
report_rows.append(f"| `{relative}` | — | 无对账钥匙 | — |")
|
||
|
|
continue
|
||
|
|
endpoint = endpoints.get(key)
|
||
|
|
complete = has_complete_structure(text)
|
||
|
|
if complete and not args.force:
|
||
|
|
if endpoint:
|
||
|
|
shape, _ = type_shape(endpoint.return_type)
|
||
|
|
counts[f"已有完整-{shape}"] += 1
|
||
|
|
report_rows.append(f"| `{relative}` | `{key[0]} {key[1]}` | 已有完整-{shape} | `{endpoint.return_type}` · `{endpoint.source.relative_to(ROOT).as_posix()}` |")
|
||
|
|
else:
|
||
|
|
counts["已有完整-未映射"] += 1
|
||
|
|
report_rows.append(f"| `{relative}` | `{key[0]} {key[1]}` | 已有完整-未映射 | — |")
|
||
|
|
continue
|
||
|
|
if endpoint:
|
||
|
|
rows, data, shape = data_structure(endpoint.return_type, classes)
|
||
|
|
keep_sample = " ## 响应示例" in text
|
||
|
|
miss = [name for name, _, desc in rows if desc == ""]
|
||
|
|
if miss:
|
||
|
|
missing_desc.append(f"{relative}: {endpoint.return_type} -> {miss}")
|
||
|
|
if args.apply:
|
||
|
|
file.write_text(patch(text, section(rows, data, keep_sample or not " ## 响应示例" in text)), encoding="utf-8", newline="")
|
||
|
|
changed += 1
|
||
|
|
tag = "强制重写" if (complete and args.force) else "待补"
|
||
|
|
counts[f"{tag}-{shape}"] += 1
|
||
|
|
report_rows.append(f"| `{relative}` | `{key[0]} {key[1]}` | {tag}-{shape} | `{endpoint.return_type}` · `{endpoint.source.relative_to(ROOT).as_posix()}` |")
|
||
|
|
else:
|
||
|
|
counts["待人工映射"] += 1
|
||
|
|
report_rows.append(f"| `{relative}` | `{key[0]} {key[1]}` | 待人工映射 | 未在 Controller 中找到 |")
|
||
|
|
lines = ["# A4 Bruno 端点与返回类型映射清单", "", "## 统计", ""]
|
||
|
|
lines.extend(f"- {name}:{value}" for name, value in sorted(counts.items()))
|
||
|
|
lines.extend(["", "## 逐端点映射", "", "| Bruno 文件 | 方法与路径 | 状态 | 返回类型与来源 |", "| --- | --- | --- | --- |", *report_rows, ""])
|
||
|
|
if missing_desc:
|
||
|
|
lines.extend(["", "## 缺 @Schema 说明的字段(待补源码注解)", "", *[f"- {x}" for x in missing_desc], ""])
|
||
|
|
REPORT.write_text("\n".join(lines), encoding="utf-8", newline="")
|
||
|
|
print(json.dumps({"total": sum(counts.values()), "changed": changed, "counts": dict(counts), "missing_desc_fields": len(missing_desc)}, ensure_ascii=False))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|