Files
xiaozhi-esp32-server/main/manager-api-fastapi/scripts/render_compatibility_doc.py
T

641 lines
30 KiB
Python
Raw Normal View History

#!/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/"): "1MIME/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 基线静态盘点
- Controller24 个、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 XML20 个,分别是 {mapper_names}
- Liquibase`db.changelog-master.yaml` 含 {changeset_refs} 个 `changeSet` 引用,目录中恰有
{len(sql_files)} 个 SQLPython 部署继续执行这 101 个原始 SQL,不改写历史。
- 定时工作:`DocumentStatusSyncTask` 每次完成后延迟 30 秒,扫描 RAGFlow RUNNING 文档并
回写 SUCCESS/FAIL/CANCEL 与统计;当前 Java 源码另有 `AgentSnapshotRedactionRunner`,启动时
执行一次并在滚动部署期每 15 秒补偿脱敏旧快照。FastAPI 将工作移到独立 jobs 进程,并以
Redis 分布式锁/watchdog 防止多 worker 重复执行。
- 外部集成:RAGFlow dataset/document/chunk/retrieval/upload;阿里云短信;火山语音克隆训练与
音频;声纹 HTTPOpenAI-compatible LLM 摘要/标题;MQTT gateway HTTPMCP/管理动作
WebSocketOTA/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/154100%**,并由",
"`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())