# -*- 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(?:(?:static|final|transient|volatile)\s+)*)?' r'(?P[\w$][\w<>?,.\[\]$]*(?:\s*<[^>]*>)*)\s+(?P\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()