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:
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a literal Go ServeMux route is absent from the OpenAPI contract."""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
ROUTE = re.compile(r'HandleFunc\("(GET|POST|PUT|PATCH|DELETE) ([^" ]+)"')
|
||||
PARAM = re.compile(r"\{[^{}]+\}")
|
||||
|
||||
|
||||
def normalized(method: str, path: str) -> tuple[str, str]:
|
||||
return method.upper(), PARAM.sub("{}", path)
|
||||
|
||||
|
||||
document = yaml.safe_load(Path("api/openapi/gateway.yaml").read_text())
|
||||
contract = {normalized(method, path) for path, value in document["paths"].items() for method in value if method.lower() in {"get", "post", "put", "patch", "delete"}}
|
||||
implemented = set()
|
||||
for source in list(Path("internal").rglob("*.go")) + list(Path("cmd").rglob("*.go")):
|
||||
for method, path in ROUTE.findall(source.read_text()):
|
||||
implemented.add((normalized(method, path), str(source)))
|
||||
missing = sorted((route, source) for route, source in implemented if route not in contract)
|
||||
for (method, path), source in missing:
|
||||
print(f"{method} {path} ({source})")
|
||||
if missing:
|
||||
raise SystemExit(f"{len(missing)} implemented literal routes are missing from OpenAPI")
|
||||
print(f"OpenAPI covers {len(implemented)} literal Go routes")
|
||||
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()
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Atomic Nginx upstream switch with validation and automatic config rollback.
|
||||
# This script performs no action unless invoked with preflight, go, legacy, or status.
|
||||
|
||||
action=${1:-status}
|
||||
active_include=${NGINX_ACTIVE_INCLUDE:-}
|
||||
go_upstream=${GO_UPSTREAM:-gateway-api:8080}
|
||||
legacy_upstream=${LEGACY_UPSTREAM:-legacy-gateway:8000}
|
||||
go_health=${GO_HEALTH_URL:-http://127.0.0.1:8080/readyz}
|
||||
legacy_health=${LEGACY_HEALTH_URL:-http://127.0.0.1:8000/readyz}
|
||||
public_health=${PUBLIC_HEALTH_URL:-}
|
||||
nginx_bin=${NGINX_BIN:-nginx}
|
||||
|
||||
probe() {
|
||||
curl -fsS --max-time 5 "$1" >/dev/null
|
||||
}
|
||||
|
||||
preflight() {
|
||||
probe "$go_health"
|
||||
probe "$legacy_health"
|
||||
"$nginx_bin" -t
|
||||
echo "preflight ok: Go and legacy are ready; Nginx configuration is valid"
|
||||
}
|
||||
|
||||
status() {
|
||||
if [ -z "$active_include" ] || [ ! -f "$active_include" ]; then
|
||||
echo "active upstream include is not configured or does not exist"
|
||||
exit 1
|
||||
fi
|
||||
sed -n '1,3p' "$active_include"
|
||||
}
|
||||
|
||||
switch_to() {
|
||||
target_name=$1
|
||||
target_address=$2
|
||||
target_health=$3
|
||||
if [ -z "$active_include" ]; then
|
||||
echo "NGINX_ACTIVE_INCLUDE is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
probe "$target_health"
|
||||
directory=$(dirname "$active_include")
|
||||
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
backup="${active_include}.backup.${timestamp}"
|
||||
temporary="${directory}/.ai-gateway-upstream.${timestamp}.tmp"
|
||||
if [ -f "$active_include" ]; then
|
||||
cp -p "$active_include" "$backup"
|
||||
fi
|
||||
umask 027
|
||||
{
|
||||
echo "# ai-gateway-active=${target_name} switched_at=${timestamp}"
|
||||
echo "upstream ai_gateway_active {"
|
||||
echo " server ${target_address};"
|
||||
echo " keepalive 128;"
|
||||
echo "}"
|
||||
} >"$temporary"
|
||||
mv "$temporary" "$active_include"
|
||||
if ! "$nginx_bin" -t; then
|
||||
if [ -f "$backup" ]; then mv "$backup" "$active_include"; fi
|
||||
echo "Nginx validation failed; previous include restored" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "$nginx_bin" -s reload; then
|
||||
if [ -f "$backup" ]; then mv "$backup" "$active_include"; "$nginx_bin" -s reload || true; fi
|
||||
echo "Nginx reload failed; previous include restored" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$public_health" ] && ! probe "$public_health"; then
|
||||
if [ -f "$backup" ]; then mv "$backup" "$active_include"; "$nginx_bin" -t; "$nginx_bin" -s reload; fi
|
||||
echo "public health probe failed; traffic rolled back" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "active upstream switched to ${target_name}; backup: ${backup}"
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
preflight) preflight ;;
|
||||
status) status ;;
|
||||
go) switch_to go "$go_upstream" "$go_health" ;;
|
||||
legacy|rollback) switch_to legacy "$legacy_upstream" "$legacy_health" ;;
|
||||
*) echo "usage: $0 {preflight|status|go|legacy|rollback}" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a legacy SQLite database as deterministic JSON Lines for staging.
|
||||
|
||||
The default omits credential/ciphertext columns. --include-encrypted retains old
|
||||
ciphertext only for a controlled offline transform; it never decrypts or logs it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SENSITIVE_MARKERS = ("password", "secret", "api_key", "raw_key", "totp", "backup_codes", "headers_template")
|
||||
|
||||
|
||||
def encode(value):
|
||||
if isinstance(value, bytes):
|
||||
return {"$binary_base64": base64.b64encode(value).decode("ascii")}
|
||||
if isinstance(value, (dt.date, dt.datetime)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("database", type=Path)
|
||||
parser.add_argument("--source-system", default="python-gateway")
|
||||
parser.add_argument("--include-encrypted", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if not args.database.is_file():
|
||||
parser.error("SQLite database does not exist")
|
||||
connection = sqlite3.connect(f"file:{args.database}?mode=ro", uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
tables = [row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")]
|
||||
for table in tables:
|
||||
columns = [row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')]
|
||||
id_column = "id" if "id" in columns else None
|
||||
if id_column is None:
|
||||
print(f"skip table without id: {table}", file=sys.stderr)
|
||||
continue
|
||||
selected = [column for column in columns if args.include_encrypted or not any(marker in column.lower() for marker in SENSITIVE_MARKERS)]
|
||||
order = f' ORDER BY "{id_column}"'
|
||||
query = "SELECT " + ",".join(f'"{column}"' for column in selected) + f' FROM "{table}"' + order
|
||||
for row in connection.execute(query):
|
||||
data = {column: encode(row[column]) for column in selected}
|
||||
record = {"source_system": args.source_system, "entity_type": table, "legacy_id": str(row[id_column]), "data": data}
|
||||
canonical = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
record["checksum"] = hashlib.sha256(canonical.encode()).hexdigest()
|
||||
print(json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export the Python gateway SQLAlchemy schema as a reviewable Markdown dictionary.
|
||||
|
||||
This is an offline migration-development tool. It is not part of the Go runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CURRENT_TARGETS = {
|
||||
"departments": "gateway.departments(已落地)",
|
||||
"providers": "gateway.providers + gateway.provider_models(已落地,models 拆表)",
|
||||
"users": "gateway.portal_users(已落地)",
|
||||
"virtual_keys": "gateway.api_keys(已落地,字段需转换)",
|
||||
"admin_accounts": "gateway.admin_accounts(已落地)",
|
||||
"audit_logs": "gateway.audit_events(已落地,不迁移无限长正文)",
|
||||
}
|
||||
|
||||
|
||||
def default_text(column) -> str:
|
||||
value = column.default
|
||||
if value is None:
|
||||
return "—"
|
||||
argument = value.arg
|
||||
if callable(argument):
|
||||
return getattr(argument, "__name__", "callable")
|
||||
return str(argument).replace("|", "\\|")
|
||||
|
||||
|
||||
def render(legacy_root: Path) -> str:
|
||||
sys.path.insert(0, str(legacy_root))
|
||||
db = importlib.import_module("app.db")
|
||||
tables = [table for name, table in sorted(db.Base.metadata.tables.items()) if name != "audit_log_views"]
|
||||
|
||||
lines = [
|
||||
"# 旧 Python 网关数据字典与 ID 迁移映射",
|
||||
"",
|
||||
"> 由 `scripts/export_legacy_dictionary.py` 从旧工程 SQLAlchemy metadata 生成。",
|
||||
"> `audit_log_views` 是兼容查询投影,不作为独立持久化实体,因此这里统计 38 个实体。",
|
||||
"",
|
||||
"## 映射约定",
|
||||
"",
|
||||
"- 旧整数主键统一通过 `gateway.legacy_id_mappings` 映射为 UUID,不在新业务表保留双主键。",
|
||||
"- UUID 使用固定 namespace 的 UUIDv5 生成,输入为 `source_system/entity_type/legacy_id`,重复导入保持幂等。",
|
||||
"- 外键迁移必须先查映射表;缺失映射时整批失败,不允许写入悬空引用。",
|
||||
"- 旧密文不能直接复用:Provider、TOTP、内部服务 Key 等敏感字段在导入时解密后用新用途标签重新加密。",
|
||||
"- `audit_logs` 的完整请求/响应正文不进入新库,只迁移受长度约束的预览与结构化元数据。",
|
||||
"",
|
||||
"## 实体去向",
|
||||
"",
|
||||
"| 旧表 | 新域/目标 | 状态 |",
|
||||
"|---|---|---|",
|
||||
]
|
||||
for table in tables:
|
||||
target = CURRENT_TARGETS.get(table.name, "对应 M2–M4 领域表")
|
||||
status = "已建表,待批量导入器" if table.name in CURRENT_TARGETS else "已登记,随对应里程碑建表"
|
||||
lines.append(f"| `{table.name}` | {target} | {status} |")
|
||||
|
||||
lines.extend(["", "## 字段字典", ""])
|
||||
for table in tables:
|
||||
lines.extend([
|
||||
f"### `{table.name}`",
|
||||
"",
|
||||
"| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 |",
|
||||
"|---|---|---:|---|---|",
|
||||
])
|
||||
for column in table.columns:
|
||||
flags = []
|
||||
if column.primary_key:
|
||||
flags.append("PK")
|
||||
if column.unique:
|
||||
flags.append("UNIQUE")
|
||||
flags.extend(f"FK → `{fk.target_fullname}`" for fk in column.foreign_keys)
|
||||
lines.append(
|
||||
f"| `{column.name}` | `{column.type}` | {'是' if column.nullable else '否'} | "
|
||||
f"`{default_text(column)}` | {';'.join(flags) or '—'} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--legacy-root", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(render(args.legacy_root.resolve()), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user