mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 07:03:53 +08:00
641 lines
30 KiB
Python
641 lines
30 KiB
Python
#!/usr/bin/env python3
|
||
"""Render the auditable Java/FastAPI compatibility matrix from checked-in inventories."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import re
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
TARGET_ROOT = Path(__file__).resolve().parents[1]
|
||
REPOSITORY_ROOT = TARGET_ROOT.parents[1]
|
||
JAVA_ROOT = REPOSITORY_ROOT / "main" / "manager-api"
|
||
JAVA_SOURCE_ROOT = JAVA_ROOT / "src" / "main" / "java"
|
||
JAVA_RESOURCE_ROOT = JAVA_ROOT / "src" / "main" / "resources"
|
||
JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json"
|
||
CONSUMER_MANIFEST = TARGET_ROOT / "compatibility" / "consumer-routes.json"
|
||
CONTRACT_RESULTS = TARGET_ROOT / "compatibility" / "contract-results.json"
|
||
ROUTE_SURFACE_RESULTS = TARGET_ROOT / "compatibility" / "route-surface-results.json"
|
||
AUTHENTICATED_ROUTE_RESULTS = TARGET_ROOT / "compatibility" / "authenticated-route-results.json"
|
||
PATH_PARAMETER = re.compile(r"\{[^/{}]+\}")
|
||
|
||
|
||
DIFFERENTIAL_CASES: dict[tuple[str, str], str] = {
|
||
("GET", "/user/pub-config"): "1",
|
||
("GET", "/user/info"): "9(七语言/过期 Token/Long)",
|
||
("GET", "/admin/users"): "3(权限/序列化/非法分页)",
|
||
("GET", "/agent/list"): "1",
|
||
("GET", "/device/bind/{}"): "1",
|
||
("GET", "/models/provider"): "1",
|
||
("GET", "/correct-word/file/list"): "1",
|
||
("GET", "/correct-word/file/download/{}"): "2(二进制/更新后下载)",
|
||
("PUT", "/device/update/{}"): "3(上下界/UTF-16 长度)",
|
||
("POST", "/models/provider"): "1(约束集合)",
|
||
("POST", "/config/server-base"): "3(缺失/错误/正确 secret)",
|
||
("GET", "/ota/"): "1(MIME/body)",
|
||
("POST", "/ota/"): "4(必填/格式/凭证/密码学)",
|
||
("POST", "/ota/activate"): "3",
|
||
("POST", "/device/tools/list/{}"): "2(响应/外呼格式)",
|
||
("POST", "/correct-word/file"): "2(响应/DB)",
|
||
("PUT", "/correct-word/file/{}"): "2(响应/DB)",
|
||
("DELETE", "/correct-word/file/{}"): "1(级联副作用)",
|
||
("POST", "/otaMag/upload"): "2(上传/扩展名错误)",
|
||
("POST", "/otaMag"): "2(响应/DB)",
|
||
("GET", "/otaMag/download/{}"): "4(次数限制及二进制)",
|
||
}
|
||
|
||
DOMAIN_TESTS = {
|
||
"AdminController": "sys",
|
||
"SysParamsController": "sys",
|
||
"SysDictDataController": "sys",
|
||
"SysDictTypeController": "sys",
|
||
"ServerSideManageController": "sys",
|
||
"LoginController": "security",
|
||
"ConfigController": "config",
|
||
"AgentController": "agent",
|
||
"AgentChatHistoryController": "agent",
|
||
"AgentMcpAccessPointController": "agent",
|
||
"AgentSnapshotController": "agent",
|
||
"AgentTemplateController": "agent",
|
||
"AgentVoicePrintController": "agent",
|
||
"CorrectWordController": "correctword",
|
||
"DeviceController": "device",
|
||
"KnowledgeBaseController": "knowledge",
|
||
"KnowledgeFilesController": "knowledge",
|
||
"ModelController": "model",
|
||
"ModelProviderController": "model",
|
||
"OTAController": "device",
|
||
"OTAMagController": "device",
|
||
"TimbreController": "timbre",
|
||
"VoiceCloneController": "voiceclone",
|
||
"VoiceResourceController": "voiceclone",
|
||
}
|
||
|
||
|
||
def _canonical(path: str) -> str:
|
||
return PATH_PARAMETER.sub("{}", path)
|
||
|
||
|
||
def _declaration(route: dict[str, Any]) -> str:
|
||
source_path = REPOSITORY_ROOT / route["source"]
|
||
lines = source_path.read_text(encoding="utf-8").splitlines()
|
||
excerpt = "\n".join(lines[int(route["line"]) - 1 : int(route["line"]) + 60])
|
||
match = re.search(r"\bpublic\s+", excerpt)
|
||
if match is None:
|
||
raise ValueError(f"public declaration not found for {route['controller']}.{route['handler']}")
|
||
declaration = excerpt[match.start() :]
|
||
depth = 0
|
||
saw_parenthesis = False
|
||
for offset, character in enumerate(declaration):
|
||
if character == "(":
|
||
depth += 1
|
||
saw_parenthesis = True
|
||
elif character == ")":
|
||
depth -= 1
|
||
elif character == "{" and saw_parenthesis and depth == 0:
|
||
return " ".join(declaration[:offset].split())
|
||
raise ValueError(f"unterminated declaration for {route['controller']}.{route['handler']}")
|
||
|
||
|
||
def _parameter_text(declaration: str, handler: str) -> str:
|
||
marker = re.search(rf"\b{re.escape(handler)}\s*\(", declaration)
|
||
if marker is None:
|
||
return ""
|
||
start = marker.end() - 1
|
||
depth = 0
|
||
for offset in range(start, len(declaration)):
|
||
character = declaration[offset]
|
||
if character == "(":
|
||
depth += 1
|
||
elif character == ")":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return declaration[start + 1 : offset]
|
||
return ""
|
||
|
||
|
||
def _split_parameters(value: str) -> list[str]:
|
||
result: list[str] = []
|
||
start = 0
|
||
round_depth = 0
|
||
angle_depth = 0
|
||
in_quote: str | None = None
|
||
escaped = False
|
||
for offset, character in enumerate(value):
|
||
if escaped:
|
||
escaped = False
|
||
continue
|
||
if character == "\\":
|
||
escaped = True
|
||
continue
|
||
if in_quote is not None:
|
||
if character == in_quote:
|
||
in_quote = None
|
||
continue
|
||
if character in {'"', "'"}:
|
||
in_quote = character
|
||
elif character == "(":
|
||
round_depth += 1
|
||
elif character == ")":
|
||
round_depth -= 1
|
||
elif character == "<":
|
||
angle_depth += 1
|
||
elif character == ">":
|
||
angle_depth -= 1
|
||
elif character == "," and round_depth == 0 and angle_depth == 0:
|
||
result.append(value[start:offset].strip())
|
||
start = offset + 1
|
||
tail = value[start:].strip()
|
||
if tail:
|
||
result.append(tail)
|
||
return result
|
||
|
||
|
||
def _without_annotations(value: str) -> str:
|
||
output: list[str] = []
|
||
offset = 0
|
||
while offset < len(value):
|
||
if value[offset] != "@":
|
||
output.append(value[offset])
|
||
offset += 1
|
||
continue
|
||
offset += 1
|
||
while offset < len(value) and (value[offset].isalnum() or value[offset] in "._$"):
|
||
offset += 1
|
||
while offset < len(value) and value[offset].isspace():
|
||
offset += 1
|
||
if offset < len(value) and value[offset] == "(":
|
||
depth = 1
|
||
offset += 1
|
||
in_quote: str | None = None
|
||
while offset < len(value) and depth:
|
||
character = value[offset]
|
||
if in_quote is not None:
|
||
if character == in_quote and value[offset - 1] != "\\":
|
||
in_quote = None
|
||
elif character in {'"', "'"}:
|
||
in_quote = character
|
||
elif character == "(":
|
||
depth += 1
|
||
elif character == ")":
|
||
depth -= 1
|
||
offset += 1
|
||
while offset < len(value) and value[offset].isspace():
|
||
offset += 1
|
||
return " ".join("".join(output).split())
|
||
|
||
|
||
def _type_and_name(parameter: str) -> tuple[str, str]:
|
||
cleaned = _without_annotations(parameter).removeprefix("final ").strip()
|
||
pieces = cleaned.rsplit(" ", 1)
|
||
if len(pieces) != 2:
|
||
return cleaned, cleaned
|
||
return pieces[0], pieces[1]
|
||
|
||
|
||
def _request_surface(route: dict[str, Any], declaration: str) -> str:
|
||
path_names = re.findall(r"\{([^/{}]+)\}", route["path"])
|
||
headers: list[str] = []
|
||
queries: list[str] = []
|
||
bodies: list[str] = []
|
||
multipart: list[str] = []
|
||
for parameter in _split_parameters(_parameter_text(declaration, route["handler"])):
|
||
parameter_type, name = _type_and_name(parameter)
|
||
if "HttpServletResponse" in parameter_type or "HttpServletRequest" in parameter_type:
|
||
continue
|
||
if "@PathVariable" in parameter:
|
||
continue
|
||
if "@RequestHeader" in parameter:
|
||
quoted = re.search(r'@RequestHeader(?:\([^)]*)?["\']([^"\']+)["\']', parameter)
|
||
headers.append(quoted.group(1) if quoted else name)
|
||
elif "MultipartFile" in parameter_type:
|
||
multipart.append(name)
|
||
elif "@RequestBody" in parameter:
|
||
bodies.append(parameter_type)
|
||
elif "@RequestParam" in parameter or "@ParameterObject" in parameter:
|
||
queries.append(f"{name}:{parameter_type}" if parameter_type != name else name)
|
||
elif route["method"] == "GET" or route["controller"] == "ModelProviderController":
|
||
queries.append(f"{name}:{parameter_type}" if parameter_type != name else name)
|
||
parts: list[str] = []
|
||
if path_names:
|
||
parts.append("Path:" + ",".join(path_names))
|
||
if headers:
|
||
parts.append("Header:" + ",".join(headers))
|
||
if queries:
|
||
parts.append("Query:" + ",".join(queries))
|
||
if bodies:
|
||
parts.append("Body:" + ",".join(bodies))
|
||
if multipart:
|
||
parts.append("Multipart:" + ",".join(multipart))
|
||
return "; ".join(parts) if parts else "—"
|
||
|
||
|
||
def _response_type(route: dict[str, Any], declaration: str) -> str:
|
||
path = route["path"]
|
||
if path == "/user/captcha":
|
||
return "image/gif 二进制"
|
||
if path == "/ota/" and route["method"] == "GET":
|
||
return "裸 text/plain"
|
||
if path.startswith("/ota/"):
|
||
return "裸 application/json"
|
||
if path in {
|
||
"/agent/play/{uuid}",
|
||
"/agent/chat-history/download/{uuid}/current",
|
||
"/agent/chat-history/download/{uuid}/previous",
|
||
"/correct-word/file/download/{fileId}",
|
||
"/otaMag/download/{uuid}",
|
||
"/voiceClone/play/{uuid}",
|
||
}:
|
||
return "流式/二进制 + 原下载 headers"
|
||
match = re.search(rf"public\s+(.+?)\s+{re.escape(route['handler'])}\s*\(", declaration)
|
||
return_type = match.group(1) if match else "unknown"
|
||
if return_type.startswith("Result<"):
|
||
return "envelope " + return_type.removeprefix("Result")
|
||
return return_type
|
||
|
||
|
||
def _permission(route: dict[str, Any]) -> str | None:
|
||
source_path = REPOSITORY_ROOT / route["source"]
|
||
lines = source_path.read_text(encoding="utf-8").splitlines()
|
||
excerpt = "\n".join(lines[int(route["line"]) - 1 : int(route["line"]) + 60])
|
||
declaration_offset = excerpt.find("public ")
|
||
decorators = excerpt if declaration_offset < 0 else excerpt[:declaration_offset]
|
||
match = re.search(r'@RequiresPermissions\(\s*"([^"]+)"', decorators)
|
||
return match.group(1) if match else None
|
||
|
||
|
||
def _side_effect(route: dict[str, Any]) -> str:
|
||
method = route["method"]
|
||
path = route["path"]
|
||
handler = route["handler"]
|
||
controller = route["controller"]
|
||
if path == "/user/captcha":
|
||
return "Redis-W(captcha TTL); GIF"
|
||
if path == "/user/login":
|
||
return "DB-R/W(token); Redis-R/DEL(captcha)"
|
||
if path == "/user/smsVerification":
|
||
return "Redis-R/W(TTL/频控); 外部-Aliyun SMS"
|
||
if path in {"/user/register", "/user/retrieve-password", "/user/change-password"}:
|
||
return "DB-W(user/token); Redis-R/DEL(SMS)"
|
||
if path in {"/user/info", "/user/pub-config"}:
|
||
return "DB-R; Redis-R/W(cache)"
|
||
if path.startswith("/admin/server/"):
|
||
return "DB/Redis-R(secret/WS); Redis-W(one-shot); 外部-WebSocket" if method == "POST" else "DB/Redis-R"
|
||
if path.startswith("/admin/params"):
|
||
if method == "GET":
|
||
return "DB-R"
|
||
if method == "PUT":
|
||
return "DB-W; Redis-W; 外部-配置端点探测(按 paramCode)"
|
||
return "DB-W; Redis-W/DEL"
|
||
if path.startswith("/admin/dict"):
|
||
return "DB-R; Redis-R/W(dict cache)" if method == "GET" else "DB-W; Redis-DEL(dict cache)"
|
||
if path == "/admin/device/all" or path == "/admin/users":
|
||
return "DB-R"
|
||
if path == "/admin/users/{id}" and method == "DELETE":
|
||
return "DB-W(用户/token/device/agent 级联)"
|
||
if path.startswith("/admin/users"):
|
||
return "DB-W(user/password/status/token)"
|
||
if path.startswith("/config/"):
|
||
return "DB-R; Redis-R/W(runtime/model/timbre cache)"
|
||
if path.startswith("/agent/mcp/tools"):
|
||
return "DB/Redis-R; 外部-WebSocket MCP"
|
||
if path.startswith("/agent/mcp/address"):
|
||
return "DB/Redis-R; AES token 生成"
|
||
if path.startswith("/agent/voice-print"):
|
||
return "DB-R" if method == "GET" else "DB-W; 外部-voiceprint HTTP"
|
||
if "/chat-summary/" in path or "/chat-title/" in path:
|
||
return "DB-R/W(chat); 外部-OpenAI-compatible LLM"
|
||
if path == "/agent/chat-history/report":
|
||
return "DB-W(chat/session); server-secret"
|
||
if path.startswith("/agent/chat-history/getDownloadUrl/"):
|
||
return "DB-R(chat/session); Redis-W(download token TTL)"
|
||
if "/chat-history/download/" in path or path.startswith("/agent/play/"):
|
||
return "DB/Redis-R(one-shot); 文件-R/流式"
|
||
if path.startswith("/agent/audio/"):
|
||
return "DB-R(audio); Redis-W(one-shot URL)"
|
||
if path.startswith("/agent/") or path == "/agent":
|
||
return "DB-R; Redis-R" if method == "GET" else "DB-W(含快照/映射/标签事务); Redis-DEL"
|
||
if path.startswith("/correct-word/"):
|
||
if "download" in path:
|
||
return "DB-R(content); 二进制"
|
||
return "DB-R" if method == "GET" else "DB-W(file/items/mapping 事务)"
|
||
if path.startswith("/datasets"):
|
||
if path == "/datasets/rag-models":
|
||
return "DB-R(model config)"
|
||
if method == "GET" and not path.endswith("/chunks"):
|
||
return "DB-R"
|
||
return "DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval)"
|
||
if path.startswith("/device/tools/") or (path == "/device/bind/{agentId}" and method == "POST"):
|
||
return "DB/Redis-R; 外部-MQTT gateway HTTP + daily auth"
|
||
if path in {"/device/address-book/call", "/device/address-book/lookup"}:
|
||
return "DB-R; 外部-MQTT gateway HTTP; server-secret"
|
||
if path.startswith("/device/"):
|
||
return "DB-R; Redis-R" if method == "GET" else "DB-W(device/bind/address-book); Redis-R/W"
|
||
if path == "/ota/":
|
||
if method == "GET":
|
||
return "—"
|
||
return "DB/Redis-R(设备/固件/配置); HMAC/Base64/时间戳凭证"
|
||
if path == "/ota/activate":
|
||
return "DB-R/W(device activation); Redis-R/W(TTL)"
|
||
if path.startswith("/otaMag/upload"):
|
||
return "文件-W(MD5/扩展名/大小)"
|
||
if path.startswith("/otaMag/download"):
|
||
return "Redis-R/W(一次性/次数); 文件-R/流式"
|
||
if path.startswith("/otaMag/getDownloadUrl"):
|
||
return "DB-R; Redis-W(download token TTL)"
|
||
if path.startswith("/otaMag"):
|
||
if method == "GET":
|
||
return "DB-R"
|
||
if method == "DELETE":
|
||
return "DB-W(OTA metadata); 文件-DEL"
|
||
return "DB-W(OTA metadata)"
|
||
if path.startswith("/models"):
|
||
return "DB-R; Redis-R/W(model cache)" if method == "GET" else "DB-W; Redis-DEL(model/config cache)"
|
||
if path.startswith("/ttsVoice"):
|
||
return "DB-R; Redis-R/W(timbre cache)" if method == "GET" else "DB-W; Redis-DEL(timbre/config cache)"
|
||
if path.startswith("/voiceClone"):
|
||
if path.startswith("/voiceClone/play"):
|
||
return "Redis-R/DEL(one-shot); 文件/外部音频-R"
|
||
if handler == "getAudioId":
|
||
return "DB-R; Redis-W(one-shot URL)"
|
||
if handler == "updateName":
|
||
return "DB-W(train record name)"
|
||
if method == "GET":
|
||
return "DB-R"
|
||
return "DB-R/W(train state); 文件-W; 外部-火山语音克隆 HTTP"
|
||
if path.startswith("/voiceResource"):
|
||
return "DB-R" if method == "GET" else "DB-W(voice resource)"
|
||
operation = "DB-R" if method == "GET" else "DB-W"
|
||
return f"{operation} ({controller}.{handler})"
|
||
|
||
|
||
def _verification(route: dict[str, Any]) -> str:
|
||
domain = DOMAIN_TESTS.get(route["controller"])
|
||
domain_status = f"领域✓({domain},域级)" if domain else "领域—"
|
||
diff = DIFFERENTIAL_CASES.get((route["method"], _canonical(route["path"])))
|
||
diff_status = f"差分✓{diff}" if diff else "差分—"
|
||
if route["path"] == "/otaMag/getDownloadUrl/{id}":
|
||
diff_status = "差分间接✓(供下载链路)"
|
||
return f"结构✓;请求面差分✓1;认证业务面差分✓1;{domain_status};{diff_status}"
|
||
|
||
|
||
def _escape(value: str) -> str:
|
||
return value.replace("|", "\\|").replace("\n", " ")
|
||
|
||
|
||
def _inventory_section(java_routes: list[dict[str, Any]]) -> str:
|
||
controller_counts = Counter(route["controller"] for route in java_routes)
|
||
mapper_files = sorted((JAVA_RESOURCE_ROOT / "mapper").rglob("*.xml"))
|
||
changelog_root = JAVA_RESOURCE_ROOT / "db" / "changelog"
|
||
sql_files = sorted(changelog_root.rglob("*.sql"))
|
||
master = changelog_root / "db.changelog-master.yaml"
|
||
changeset_refs = master.read_text(encoding="utf-8").count("changeSet:")
|
||
entity_files = list(JAVA_SOURCE_ROOT.rglob("entity/*.java"))
|
||
dto_files = [path for path in JAVA_SOURCE_ROOT.rglob("*.java") if "dto" in path.parts]
|
||
vo_files = list(JAVA_SOURCE_ROOT.rglob("vo/*.java"))
|
||
dao_files = list(JAVA_SOURCE_ROOT.rglob("dao/*.java"))
|
||
service_files = list(JAVA_SOURCE_ROOT.rglob("service/**/*.java"))
|
||
service_impl_files = list(JAVA_SOURCE_ROOT.rglob("service/impl/*.java"))
|
||
assert len(controller_counts) == 24
|
||
assert len(mapper_files) == 20
|
||
assert len(sql_files) == 101
|
||
assert changeset_refs == 101
|
||
assert len(entity_files) == 29
|
||
assert len(dto_files) == 58
|
||
assert len(vo_files) == 14
|
||
assert len(dao_files) == 29
|
||
mapper_names = "、".join(f"`{path.relative_to(JAVA_RESOURCE_ROOT).as_posix()}`" for path in mapper_files)
|
||
controller_names = "、".join(
|
||
f"`{controller}`({controller_counts[controller]})" for controller in sorted(controller_counts)
|
||
)
|
||
return f"""## Java 基线静态盘点
|
||
|
||
- Controller:24 个、154 条映射。按 Controller 的路由数为:{controller_names}。
|
||
- 数据分层:`entity/` 29 个 Java 文件(28 个 `*Entity.java` 加 `BaseEntity`)、`dto/` 58 个、
|
||
`vo/` 14 个、`dao/` 29 个、`service/` 树 {len(service_files)} 个文件(其中
|
||
`service/impl/` {len(service_impl_files)} 个)。FastAPI 对应落在 `schemas/`、`repositories/`、
|
||
`services/`、`routers/`、`integrations/` 与 `jobs/`,没有把跨表事务放进路由。
|
||
- MyBatis XML:20 个,分别是 {mapper_names}。
|
||
- Liquibase:`db.changelog-master.yaml` 含 {changeset_refs} 个 `changeSet` 引用,目录中恰有
|
||
{len(sql_files)} 个 SQL;Python 部署继续执行这 101 个原始 SQL,不改写历史。
|
||
- 定时工作:`DocumentStatusSyncTask` 每次完成后延迟 30 秒,扫描 RAGFlow RUNNING 文档并
|
||
回写 SUCCESS/FAIL/CANCEL 与统计;当前 Java 源码另有 `AgentSnapshotRedactionRunner`,启动时
|
||
执行一次并在滚动部署期每 15 秒补偿脱敏旧快照。FastAPI 将工作移到独立 jobs 进程,并以
|
||
Redis 分布式锁/watchdog 防止多 worker 重复执行。
|
||
- 外部集成:RAGFlow dataset/document/chunk/retrieval/upload;阿里云短信;火山语音克隆训练与
|
||
音频;声纹 HTTP;OpenAI-compatible LLM 摘要/标题;MQTT gateway HTTP;MCP/管理动作
|
||
WebSocket;OTA/WS/MQTT 的 HMAC、Base64、时间戳与下载文件存储。自动测试只访问可重复 mock,
|
||
未使用真实付费凭证。
|
||
"""
|
||
|
||
|
||
def _require_complete_route_report(
|
||
report: dict[str, Any],
|
||
routes: list[dict[str, Any]],
|
||
*,
|
||
request_profile: str,
|
||
side_effect_policy: str,
|
||
) -> None:
|
||
expected_summary = {"total": 154, "passed": 154, "failed": 0, "skipped": 0}
|
||
expected_names = [f"{route['method']} {route['path']}" for route in routes]
|
||
assert report["summary"] == expected_summary
|
||
assert report["coverage"] == {
|
||
"java_routes": 154,
|
||
"request_profile": request_profile,
|
||
"side_effect_policy": side_effect_policy,
|
||
}
|
||
assert [result["name"] for result in report["results"]] == expected_names
|
||
assert all(result["passed"] is True and result["difference"] is None for result in report["results"])
|
||
|
||
|
||
def render() -> str:
|
||
java_manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))
|
||
consumer_manifest = json.loads(CONSUMER_MANIFEST.read_text(encoding="utf-8"))
|
||
contract = json.loads(CONTRACT_RESULTS.read_text(encoding="utf-8"))
|
||
route_surface = json.loads(ROUTE_SURFACE_RESULTS.read_text(encoding="utf-8"))
|
||
authenticated_route = json.loads(AUTHENTICATED_ROUTE_RESULTS.read_text(encoding="utf-8"))
|
||
routes: list[dict[str, Any]] = java_manifest["routes"]
|
||
assert len(routes) == 154
|
||
assert contract["summary"] == {"total": 49, "passed": 49, "failed": 0, "skipped": 0}
|
||
_require_complete_route_report(
|
||
route_surface,
|
||
routes,
|
||
request_profile="missing-auth-or-safe-invalid-input",
|
||
side_effect_policy="no successful write request is issued",
|
||
)
|
||
_require_complete_route_report(
|
||
authenticated_route,
|
||
routes,
|
||
request_profile="authenticated-safe-business-or-validation",
|
||
side_effect_policy="no intentional successful writes",
|
||
)
|
||
assert len(DIFFERENTIAL_CASES) == 21
|
||
|
||
lines = [
|
||
"# manager-api FastAPI 兼容性矩阵",
|
||
"",
|
||
"> 生成依据:`main/manager-api-fastapi/compatibility/java-routes.json`、",
|
||
"> `main/manager-api-fastapi/compatibility/consumer-routes.json`、`route-surface-results.json`、",
|
||
"> `authenticated-route-results.json`、`contract-results.json` 和当前 Java 源码。接口路径均省略",
|
||
"> 共同前缀 `/xiaozhi`。",
|
||
"",
|
||
"## 结论与状态口径",
|
||
"",
|
||
"Java 基线共有 **154** 条 Spring MVC 路由;FastAPI 已注册 **154/154(100%)**,并由",
|
||
"`tests/test_java_route_manifest.py` 对源码清单 freshness、数量和 method/path 注册闭合进行检查。",
|
||
"此外实现 3 条仅由仓库消费者使用、Java Controller 中不存在的兼容路由,因此这 3 条不计入",
|
||
"154 条 Java 覆盖率。三端 188 个调用点均能解析到 FastAPI 路由。",
|
||
"",
|
||
"矩阵状态必须按下列含义阅读:",
|
||
"",
|
||
"- `结构✓`:method/path 已注册且清单闭合;它不等于业务行为逐接口实测。",
|
||
"- `请求面差分✓1`:本行已向隔离 Java/FastAPI 各发送一次缺少鉴权或安全非法输入,精确比较",
|
||
" HTTP status、body 与 Content-Type;最终为 **154/154 通过、0 失败、0 跳过**,且不发送成功写请求。",
|
||
"- `认证业务面差分✓1`:本行已使用有效 DB Token、server-secret 或匿名身份,再向隔离",
|
||
" Java/FastAPI 各发送一次安全业务/校验请求,精确比较 HTTP status、body 与 Content-Type;",
|
||
" 最终为 **154/154 通过、0 失败、0 跳过**,且不主动发送成功写请求。该状态不等于每条路由的",
|
||
" 完整成功生命周期均已差分,完整副作用证据仍以 `差分✓N` 为准。",
|
||
"- `领域✓(x,域级)`:该领域有 service/repository/协议自动测试,但不保证本行每条成功与错误路径",
|
||
" 都被直接请求。`领域—` 表示除结构测试外没有可归属的域级直接测试证据。",
|
||
"- `差分✓N`:本行除安全请求面外,还参与了成功、主要错误、协议或数据库副作用的深度对照;",
|
||
" 括号说明覆盖面。深度结果为 **49/49 checks 通过、0 失败、0 跳过**,覆盖 **21/154** 条路由。",
|
||
" `差分间接✓` 表示 J125 作为下载链路的 URL 生成步骤被间接覆盖;`差分—` 表示没有深度对照,",
|
||
" 不能把 154/154 请求面差分误读成 154 条全部成功路径与副作用都已逐接口对照。",
|
||
"- 所有 `Result<T>` 均表示 `{code,msg,data}` envelope;原 Java 为 HTTP 200 的认证、权限、业务和",
|
||
" 参数错误由全局兼容层维持 HTTP 200。二进制/OTA 裸响应在“响应类型”列单独标明。",
|
||
"",
|
||
"## 三端消费者闭合",
|
||
"",
|
||
"| 消费者 | 调用点 | 唯一结构路由 | 方法分布 |",
|
||
"|---|---:|---:|---|",
|
||
]
|
||
for consumer in ("manager-web", "manager-mobile", "xiaozhi-server"):
|
||
item = consumer_manifest["consumers"][consumer]
|
||
methods = "、".join(f"{method} {count}" for method, count in item["methods"].items())
|
||
lines.append(f"| `{consumer}` | {item['callSites']} | {item['uniqueRoutes']} | {methods} |")
|
||
lines.extend(
|
||
[
|
||
f"| **合计** | **{consumer_manifest['count']}** | **{consumer_manifest['uniqueRoutes']}** | — |",
|
||
"",
|
||
"### 3 条消费者孤儿兼容路由",
|
||
"",
|
||
"| Method/path | 来源 | FastAPI 语义 | 鉴权 | 状态 |",
|
||
"|---|---|---|---|---|",
|
||
(
|
||
"| `GET /api/ping` | manager-mobile 环境设置探活 | "
|
||
"`{code:0,msg:\"success\",data:\"pong\"}` | 匿名 | 实现✓;consumer resolve✓ |"
|
||
),
|
||
(
|
||
"| `PUT /user/configDevice/{device_id}` | manager-web 遗留设备配置调用 | "
|
||
"按现有设备更新契约处理 body | DB Token | 实现✓;consumer resolve✓ |"
|
||
),
|
||
(
|
||
"| `GET /device/address-book/lookup` | xiaozhi-server 管理客户端 | "
|
||
"`callerMac/nickname/answer` 地址簿查询/呼叫兼容别名 | server-secret | "
|
||
"实现✓;consumer resolve✓;device 域测试✓ |"
|
||
),
|
||
"",
|
||
"`GET /admin/dict/data/type/FIRMWARE_TYPE` 是动态 Java 路由",
|
||
"`GET /admin/dict/data/type/{dictType}` 的一个字面调用,不是第四条孤儿路由。",
|
||
"",
|
||
_inventory_section(routes).rstrip(),
|
||
"",
|
||
"## 154 条 Java→FastAPI 逐接口矩阵",
|
||
"",
|
||
"副作用缩写:`DB-R/W`=数据库读/写,`Redis-R/W/DEL`=缓存读/写/失效,`文件-R/W`=文件",
|
||
"读取/写入;外部调用均在 service/integration 层。权限为空时表示只需对应鉴权身份。",
|
||
"",
|
||
(
|
||
"| # | Method/path | Java Controller.handler | 请求面 | 响应类型 | 鉴权 / 权限 | "
|
||
"DB/Redis/文件/外部副作用 | 实现与测试状态 |"
|
||
),
|
||
"|---:|---|---|---|---|---|---|---|",
|
||
]
|
||
)
|
||
for index, route in enumerate(routes, start=1):
|
||
declaration = _declaration(route)
|
||
permission = _permission(route) or "—"
|
||
auth = {
|
||
"anonymous": "匿名",
|
||
"database-token": "DB Token",
|
||
"server-secret": "server-secret",
|
||
}[route["auth"]]
|
||
values = [
|
||
f"J{index:03d}",
|
||
f"`{route['method']} {route['path']}`",
|
||
f"`{route['controller']}.{route['handler']}`",
|
||
_request_surface(route, declaration),
|
||
_response_type(route, declaration),
|
||
f"{auth} / `{permission}`" if permission != "—" else f"{auth} / —",
|
||
_side_effect(route),
|
||
_verification(route),
|
||
]
|
||
lines.append("| " + " | ".join(_escape(value) for value in values) + " |")
|
||
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## 已观测差异与未覆盖面",
|
||
"",
|
||
"- 154 条安全请求面差分最终全部一致。首轮曾发现 5 个空 Body 映射差异;修复 FastAPI 对",
|
||
" Spring `HttpMessageNotReadableException` 的 code-500 语义后,重新从零执行才得到 154/154。",
|
||
"- 154 条认证业务面差分最终全部一致;该轮使用有效鉴权与安全业务/校验输入,在不主动成功",
|
||
" 写入的前提下逐路由对照。证据是 `authenticated-route-results.json`,渲染器会在结果不是",
|
||
" 154/154、存在失败或跳过时硬失败。",
|
||
"- 2026-07-20 的隔离差分报告未在 49 个 checks 中观测到响应/所选 headers/数据库副作用",
|
||
" 不一致;证据是 `main/manager-api-fastapi/compatibility/contract-results.json`,不是人工推断。",
|
||
"- Hibernate Validator 的 `ConstraintViolation Set` 首条消息无稳定顺序;模型 provider 必填",
|
||
" 用例比较“消息属于 Java 声明约束集合”与相同错误码,而不伪造一个固定顺序。",
|
||
"- OTA 时间戳/token 是动态值,差分先比较归一化结构,再分别校验两端 HMAC/Base64 密码学",
|
||
" 有效性;这属于有意的测试归一化,不是声称字节恒等。",
|
||
"- 深度差分未直接命中的 133 条中,J125 是下载链路间接覆盖,另 132 条标为 `差分—`;",
|
||
" 它们有请求面、认证业务面与所属领域测试,但尚无逐路由成功+主要错误+副作用深度对照,不能据此宣称",
|
||
" 每一种业务状态均已逐接口行为等价。",
|
||
"- FastAPI 额外提供上述 3 条消费者兼容路由与 live/ready 健康检查;它们没有 Java",
|
||
" Controller 基线,属于明确、可回退的加法差异。",
|
||
"- Java 把定时任务放在 Spring 进程;FastAPI 使用独立 jobs 进程和 Redis 分布式锁。这是",
|
||
" 部署拓扑差异,业务状态和幂等目标保持一致。",
|
||
"- RAGFlow、阿里云短信、火山语音克隆、真实声纹、真实 LLM、真实 MQTT/MCP/WS 均未用",
|
||
" 生产凭证联调;自动化只证明 mock 请求格式、超时/错误映射/重试中的已覆盖场景。",
|
||
"",
|
||
"## 可复现检查",
|
||
"",
|
||
"```bash",
|
||
"cd main/manager-api-fastapi",
|
||
".venv/bin/python scripts/extract_java_routes.py --output compatibility/java-routes.json",
|
||
".venv/bin/python scripts/extract_consumer_routes.py > /tmp/consumer-routes.json",
|
||
(
|
||
".venv/bin/pytest -q tests/test_java_route_manifest.py "
|
||
"tests/test_consumer_route_manifest.py tests/test_compatibility_document.py"
|
||
),
|
||
"```",
|
||
"",
|
||
"逐接口差分的启动、隔离库、mock 与执行命令见 `docs/manager-api-fastapi-test-report.md`;",
|
||
"本文件只陈述已落盘的结果,不把缺少真实密钥的外部联调列为通过。",
|
||
]
|
||
)
|
||
rendered = "\n".join(lines) + "\n"
|
||
if len(re.findall(r"^\| J\d{3} \|", rendered, flags=re.MULTILINE)) != 154:
|
||
raise AssertionError("rendered route matrix is not closed")
|
||
return rendered
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--output", type=Path)
|
||
args = parser.parse_args()
|
||
rendered = render()
|
||
if args.output is None:
|
||
print(rendered, end="")
|
||
else:
|
||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||
args.output.write_text(rendered, encoding="utf-8")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|