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:
ben
2026-08-12 11:45:54 +08:00
commit 5759c1862e
807 changed files with 114727 additions and 0 deletions
+203
View File
@@ -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()