AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Executable
+203
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic legacy FastAPI -> Go OpenAPI route contract matrix.
|
||||
|
||||
The script uses Python AST only; importing the legacy application is deliberately
|
||||
avoided so the audit does not require its runtime dependencies or secrets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
METHODS = {"get", "post", "put", "patch", "delete"}
|
||||
PARAM = re.compile(r"\{[^{}]+\}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Route:
|
||||
method: str
|
||||
path: str
|
||||
source: str
|
||||
line: int
|
||||
|
||||
|
||||
def literal(node: ast.AST | None, default: str = "") -> str:
|
||||
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else default
|
||||
|
||||
|
||||
def legacy_routes(root: Path) -> list[Route]:
|
||||
result: list[Route] = []
|
||||
for source in sorted((root / "app").glob("*.py")):
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
||||
prefixes: dict[str, str] = {"app": ""}
|
||||
for node in tree.body:
|
||||
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||
continue
|
||||
value = node.value
|
||||
if not isinstance(value, ast.Call) or not isinstance(value.func, ast.Name) or value.func.id != "APIRouter":
|
||||
continue
|
||||
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||
prefix = next((literal(item.value) for item in value.keywords if item.arg == "prefix"), "")
|
||||
for target in targets:
|
||||
if isinstance(target, ast.Name):
|
||||
prefixes[target.id] = prefix
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
for decorator in node.decorator_list:
|
||||
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
|
||||
continue
|
||||
owner = decorator.func.value
|
||||
if not isinstance(owner, ast.Name) or decorator.func.attr.lower() not in METHODS or not decorator.args:
|
||||
continue
|
||||
route_path = literal(decorator.args[0], "")
|
||||
if route_path == "" and not (isinstance(decorator.args[0], ast.Constant) and decorator.args[0].value == ""):
|
||||
continue
|
||||
full = (prefixes.get(owner.id, "") + route_path) or "/"
|
||||
result.append(Route(decorator.func.attr.upper(), full, source.name, decorator.lineno))
|
||||
return sorted(set(result), key=lambda item: (item.path, item.method, item.source, item.line))
|
||||
|
||||
|
||||
def current_routes(spec: Path) -> set[tuple[str, str]]:
|
||||
document = yaml.safe_load(spec.read_text(encoding="utf-8"))
|
||||
return {(method.upper(), path) for path, item in document["paths"].items() for method in item if method.lower() in METHODS}
|
||||
|
||||
|
||||
def canonical(path: str) -> str:
|
||||
if path.startswith("/admin/api"):
|
||||
path = "/api/v1/admin" + path[len("/admin/api"):]
|
||||
elif path.startswith("/portal/api"):
|
||||
path = "/api/v1/portal" + path[len("/portal/api"):]
|
||||
return PARAM.sub("{}", path.rstrip("/") or "/")
|
||||
|
||||
|
||||
ALIASES = {
|
||||
"/api/v1/admin/keys": "/api/v1/admin/api-keys",
|
||||
"/api/v1/admin/kb": "/api/v1/admin/knowledge-bases",
|
||||
"/api/v1/admin/model_pricing": "/api/v1/admin/model-prices",
|
||||
"/api/v1/admin/logs": "/api/v1/admin/audit-events",
|
||||
"/api/v1/admin/users": "/api/v1/admin/identities/portal-users",
|
||||
"/api/v1/admin/admins": "/api/v1/admin/identities/admins",
|
||||
"/api/v1/admin/system_info": "/api/v1/admin/system-info",
|
||||
"/api/v1/admin/totp/backup_codes": "/api/v1/admin/totp/backup-codes",
|
||||
"/api/v1/portal/totp/backup_codes": "/api/v1/portal/totp/backup-codes",
|
||||
"/v1/apps": "/v1/applications",
|
||||
}
|
||||
|
||||
# Capability replacements whose new API intentionally does not mirror the old
|
||||
# path shape. They remain migration work (client payload/response validation),
|
||||
# but are not incorrectly reported as an unimplemented domain.
|
||||
CAPABILITY_REPLACEMENTS = [
|
||||
(re.compile(r"^/api/v1/admin/(rules|rule_dimensions|content_audit_scopes)(/|$)"), "/api/v1/admin/content-policies"),
|
||||
(re.compile(r"^/api/v1/admin/(rate_limit_rules|model_qps)(/|$)"), "/api/v1/admin/api-keys and /api/v1/admin/model-routes"),
|
||||
(re.compile(r"^/api/v1/admin/(model_aliases|routing_groups)(/|$)"), "/api/v1/admin/model-routes"),
|
||||
(re.compile(r"^/api/v1/admin/(stats|dept_stats|cost_report|pricing_config|dept_budgets)(/|$)"), "/api/v1/admin/usage/daily and /api/v1/admin/model-prices"),
|
||||
(re.compile(r"^/api/v1/admin/(notify_config)(/|$)"), "/api/v1/admin/notification-channels"),
|
||||
(re.compile(r"^/api/v1/admin/(logs|log_search|log_views)(/|$)"), "/api/v1/admin/audit-events"),
|
||||
(re.compile(r"^/api/v1/admin/malicious_domains(/|$)"), "/api/v1/admin/content-policies"),
|
||||
(re.compile(r"^/api/v1/admin/prompts/categories(/|$)"), "/api/v1/admin/prompt-categories"),
|
||||
(re.compile(r"^/api/v1/admin/prompts/\{\}/preview$"), "/api/v1/admin/prompts/{prompt_id}/render"),
|
||||
(re.compile(r"^/api/v1/admin/knowledge-bases/\{\}/documents/(paste|upload)$"), "/api/v1/admin/knowledge-bases/{knowledge_base_id}/documents"),
|
||||
(re.compile(r"^/api/v1/admin/knowledge-bases/\{\}/test_search$"), "/api/v1/admin/knowledge-bases/{knowledge_base_id}/search"),
|
||||
(re.compile(r"^/api/v1/admin/tools/\{\}/test_invoke$"), "/api/v1/admin/tools/{tool_id}/test"),
|
||||
(re.compile(r"^/api/v1/admin/applications/\{\}/composition$"), "/api/v1/admin/applications/{application_id}"),
|
||||
(re.compile(r"^/api/v1/admin/audit_settings(/|$)"), "/api/v1/admin/content-policies and /api/v1/admin/notification-channels"),
|
||||
(re.compile(r"^/api/v1/admin/feishu_auth(/|$)"), "/api/v1/admin/identity-providers (generic OIDC/SAML)"),
|
||||
(re.compile(r"^/api/v1/portal/feishu(/|$)"), "/api/v1/portal/sso (generic OIDC/SAML)"),
|
||||
(re.compile(r"^/api/v1/admin/providers/\{\}/backends(/|$)"), "/api/v1/admin/providers/{provider_id}/models and /api/v1/admin/model-routes"),
|
||||
(re.compile(r"^/api/v1/admin/providers/\{\}/fetch_models$"), "/api/v1/admin/providers/{provider_id}/models/sync"),
|
||||
(re.compile(r"^/api/v1/admin/audit-events(/search|/\{\}/view_history)?$"), "/api/v1/admin/audit-events (bounded immutable audit query)"),
|
||||
(re.compile(r"^/api/v1/admin/applications/\{\}/test$"), "/v1/applications/{application_code}/chat/completions"),
|
||||
(re.compile(r"^/api/v1/admin/applications/\{\}/keys$"), "/api/v1/admin/api-keys (scoped credentials)"),
|
||||
(re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/approve$"), "/api/v1/admin/identities/portal-users/{identity_id} active=true"),
|
||||
(re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/reset_password$"), "/api/v1/admin/identities/portal-users/{identity_id} password field"),
|
||||
(re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/feishu$"), "/api/v1/admin/identity-providers (generic identity link lifecycle)"),
|
||||
(re.compile(r"^/api/v1/admin/identities/(admins|portal-users)/\{\}$"), "/api/v1/admin/identities/*/{identity_id} active=false"),
|
||||
(re.compile(r"^/api/v1/admin/departments/\{\}$"), "/api/v1/admin/departments/{department_id} active=false"),
|
||||
(re.compile(r"^/api/v1/admin/identities/admins/change_password$"), "/api/v1/admin/password"),
|
||||
(re.compile(r"^/api/v1/admin/api-keys/\{\}$"), "/api/v1/admin/api-keys/{api_key_id}/limits"),
|
||||
(re.compile(r"^/api/v1/admin/providers/\{\}$"), "/api/v1/admin/providers/{provider_id} enabled=false"),
|
||||
(re.compile(r"^/v1/apps/\{\}/chat/completions$"), "/v1/applications/{application_code}/chat/completions"),
|
||||
]
|
||||
|
||||
RETIRED = [
|
||||
(re.compile(r"^/(admin|portal)/ui$"), "Art Design Pro SPA deployment"),
|
||||
(re.compile(r"^/api/v1/admin/logs/\{\}/view_raw$"), "retired: unbounded raw-body access is excluded by storage/compliance ADR"),
|
||||
(re.compile(r"^/api/v1/admin/logs$"), "retired: immutable audit data is removed only by the configured retention worker"),
|
||||
(re.compile(r"^/api/v1/admin/tools/\{\}/headers$"), "retired: decrypted secret headers are intentionally write-only"),
|
||||
]
|
||||
|
||||
|
||||
def alias(path: str) -> str:
|
||||
for old, new in sorted(ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
|
||||
if path == old or path.startswith(old + "/"):
|
||||
return new + path[len(old):]
|
||||
return path
|
||||
|
||||
|
||||
def compare(legacy: list[Route], current: set[tuple[str, str]]) -> list[tuple[Route, str, str]]:
|
||||
normalized_current = {(method, canonical(path)): path for method, path in current}
|
||||
rows = []
|
||||
for route in legacy:
|
||||
expected = canonical(route.path)
|
||||
direct = normalized_current.get((route.method, expected))
|
||||
if direct:
|
||||
rows.append((route, "covered", direct))
|
||||
continue
|
||||
replacement = alias(expected)
|
||||
target = normalized_current.get((route.method, replacement))
|
||||
if target:
|
||||
rows.append((route, "replaced", target))
|
||||
continue
|
||||
if route.method == "PATCH":
|
||||
target = normalized_current.get(("PUT", replacement))
|
||||
if target:
|
||||
rows.append((route, "replaced", target + " (PUT with full revision payload)"))
|
||||
continue
|
||||
decision = next((target for pattern, target in CAPABILITY_REPLACEMENTS if pattern.search(replacement)), None)
|
||||
if decision:
|
||||
rows.append((route, "replaced", decision))
|
||||
continue
|
||||
retirement = next((target for pattern, target in RETIRED if pattern.search(expected)), None)
|
||||
rows.append((route, "retired" if retirement else "missing", retirement or replacement))
|
||||
return rows
|
||||
|
||||
|
||||
def write_markdown(rows: list[tuple[Route, str, str]], destination: Path) -> None:
|
||||
counts = {status: sum(1 for _, value, _ in rows if value == status) for status in ("covered", "replaced", "retired", "missing")}
|
||||
lines = [
|
||||
"# 旧 Python → Go 路由契约矩阵", "",
|
||||
"> 由 `scripts/compare_route_contracts.py` 通过 AST 与 OpenAPI 自动生成;`replaced` 表示能力有新契约承接,仍需兼容层或客户端迁移,不能视为原路由原样可用。", "",
|
||||
f"- 旧源码路由装饰器:{len(rows)}", f"- 同方法同规范路径:{counts['covered']}",
|
||||
f"- 新契约替代:{counts['replaced']}", f"- 明确退役:{counts['retired']}", f"- 尚无契约:{counts['missing']}", "",
|
||||
"| 状态 | 方法 | 旧路径 | Go 契约/预期目标 | 来源 |", "|---|---|---|---|---|",
|
||||
]
|
||||
labels = {"covered": "已覆盖", "replaced": "新契约替代", "retired": "明确退役", "missing": "缺口"}
|
||||
for route, status, target in rows:
|
||||
lines.append(f"| {labels[status]} | `{route.method}` | `{route.path or '/'}` | `{target}` | `{route.source}:{route.line}` |")
|
||||
destination.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--legacy-root", type=Path, default=Path("/home/ben/ai-gateway-work"))
|
||||
parser.add_argument("--openapi", type=Path, default=Path("api/openapi/gateway.yaml"))
|
||||
parser.add_argument("--output", type=Path, default=Path("docs/route-contract-matrix.md"))
|
||||
parser.add_argument("--fail-on-missing", action="store_true", help="return a non-zero status when an undecided route remains")
|
||||
args = parser.parse_args()
|
||||
rows = compare(legacy_routes(args.legacy_root), current_routes(args.openapi))
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_markdown(rows, args.output)
|
||||
counts = {status: sum(1 for _, value, _ in rows if value == status) for status in ("covered", "replaced", "retired", "missing")}
|
||||
print(f"legacy={len(rows)} covered={counts['covered']} replaced={counts['replaced']} retired={counts['retired']} missing={counts['missing']}")
|
||||
if args.fail_on_missing and counts["missing"]:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user