fix(external-systems): 修复 MES 接口路径前缀

This commit is contained in:
caoqianming 2026-08-04 17:28:13 +08:00
parent cb9a39f33d
commit c489210b2a
5 changed files with 153 additions and 7 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.61.1 — 2026-08-04
- 修复部分 MES 已成功连接、也能搜索接口,但实际查询始终返回 404 的问题;现在会自动识别接口规范声明的 `/api`、`/v1` 等业务路径前缀,无需修改现有连接配置。
## 0.61.0 — 2026-08-04
- 新增“外部系统”能力:管理员可以在管理后台配置可信的 Factory MES、选择对全部用户或指定用户开放获权用户在工作台填写自己的 MES 账号后,即可让助手按本人在 MES 中的原有权限查询信息。账号密码加密保存且不会回显,接口默认只允许只读调用。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-04(Factory MES 外部系统目录、用户授权与只读查询,bump 0.61.0)
最后更新:2026-08-04(MES OpenAPI 业务路径前缀兼容,bump 0.61.1)
---
@ -23,6 +23,8 @@
### 2026-08-04
- **08-04 / 0.61.1 / MES OpenAPI 业务路径前缀兼容**:生产 task `1ade8062` 显示光芯 MES 登录与 OpenAPI 搜索均成功,但 operation path `/mtm/...` 直接拼到主机根路径后被 nginx 404真实业务路由位于 `/api/mtm/...`。Factory 连接器现读取 Swagger 2 `basePath` 与 OpenAPI 3 `servers` 的同源路径前缀,兼容规范 path 或管理员 Base URL 已含前缀的情况且不重复拼接;登录路径继续独立解析,跨主机 server 仍拒绝。外部系统与无 DB Web 路由共 34 项 unittest、Ruff 致命规则及 diff 检查通过mypy 仍报告既有 `core/external_systems/service.py:242` SQLAlchemy `rowcount` 类型问题,本次未改该文件;无 schema、migration、配置或依赖变化未写生产 DB。
- **08-04 / 0.61.0 / Factory MES 外部系统目录 + 按用户授权查询**:新增管理员维护的可信外部系统目录(Base URL/OpenAPI/登录路径/只读 POST allowlist),支持“全部用户/指定用户”可见范围;普通用户只选择获权 MES 并提交自己的账号密码,凭据以独立 `ZCBOT_CREDENTIAL_MASTER_KEY` 强制加密、不回显且不进 prompt/日志/沙箱。持久化收敛为 `external_system_definitions` + `external_systems` 两表,后者兼作 selected 授权与用户连接pending 未配凭据、active 才挂三个 host-side 元工具管理员撤权立即删密文并阻断调用Swagger operationId 是唯一调用入口GET/HEAD 默认只读POST 逐项放行。新增 0026 migration、管理后台/用户“外部”界面及 API完整 463 项 Python 全绿(17 skip)16 项 Node 回归、JS/Python 语法、Alembic 单 head、PostgreSQL DDL 编译与 diff 检查通过;未配置 `ZCBOT_TEST_DB_URL`,未连接或迁移任何数据库。
- **08-04 / 0.60.29 / PPT 总院红候选硬门 + 别名直达**:新建 PPT 未说明所属机构时,模板 / 品牌 / visual_style 候选必须包含 `zongyuan_red`;明确外部机构或其他唯一模板时不强制加入。总院、中国建材、中建材、建材集团、CNBM 等“机构词 × 模板/风格词”表达直接归一到总院红,不再追问具体模板;删除易混淆的通用 `business-red` 品牌预设,普通商务红仅保留为即时配色候选。新增路由回归测试,相关 5 项 unittest、品牌索引 JSON 校验及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.61.0"
__version__ = "0.61.1"

View File

@ -170,6 +170,70 @@ class FactoryMesClient:
})
return results
def _spec_base_path(self, spec: dict[str, Any]) -> str:
"""Return the API path prefix declared by Swagger 2 / OpenAPI 3.
The remote specification may describe a different host, but an external
system definition is the only authority allowed to choose the target
origin. OpenAPI ``servers`` therefore contributes only a same-origin
path prefix.
"""
raw_base_path = spec.get("basePath")
if raw_base_path is not None:
if not isinstance(raw_base_path, str) or not raw_base_path.startswith("/"):
raise FactoryMesError("Swagger basePath 必须是站内绝对路径")
parsed = urlparse(raw_base_path)
if parsed.netloc or parsed.query or parsed.fragment or "://" in raw_base_path:
raise FactoryMesError("Swagger basePath 非法")
return parsed.path.rstrip("/")
servers = spec.get("servers")
if not isinstance(servers, list) or not servers:
return ""
server = servers[0]
raw_url = server.get("url") if isinstance(server, dict) else None
if not isinstance(raw_url, str) or not raw_url.strip():
raise FactoryMesError("OpenAPI server URL 无效")
raw_url = raw_url.strip()
if "{" in raw_url or "}" in raw_url:
raise FactoryMesError("OpenAPI server URL 包含未解析变量")
declared = urlparse(raw_url)
base = urlparse(self.cfg.base_url)
if declared.netloc and (declared.scheme, declared.netloc) != (
base.scheme,
base.netloc,
):
raise FactoryMesError("OpenAPI server 越出 Factory MES 主机")
if declared.query or declared.fragment:
raise FactoryMesError("OpenAPI server URL 不能包含查询或片段")
return ("/" + declared.path.lstrip("/")).rstrip("/")
def _operation_url(self, spec: dict[str, Any], operation_path: str) -> str:
prefix = self._spec_base_path(spec)
base = urlparse(self.cfg.base_url)
configured_path = base.path.rstrip("/")
path = operation_path
configured_has_prefix = bool(prefix) and (
configured_path == prefix or configured_path.endswith(prefix)
)
operation_has_prefix = bool(prefix) and (
path == prefix or path.startswith(prefix + "/")
)
if configured_has_prefix and operation_has_prefix:
path = path[len(prefix):] or "/"
elif prefix and not configured_has_prefix and not operation_has_prefix:
path = prefix + "/" + path.lstrip("/")
combined_path = "/".join(
part.strip("/") for part in (configured_path, path) if part.strip("/")
)
if operation_path.endswith("/") and combined_path:
combined_path += "/"
url = urljoin(f"{base.scheme}://{base.netloc}/", combined_path)
call_origin = urlparse(url)
if (call_origin.scheme, call_origin.netloc) != (base.scheme, base.netloc):
raise FactoryMesError("接口目标越出 Factory MES 主机")
return url
def test_connection(self) -> dict[str, Any]:
token = self.authenticate()
spec = self._fetch_spec(token)
@ -257,11 +321,7 @@ class FactoryMesClient:
if "{" in path or "}" in path:
raise FactoryMesError("路径参数未完整提供")
url = urljoin(self.cfg.base_url + "/", path.lstrip("/"))
base_origin = urlparse(self.cfg.base_url)
call_origin = urlparse(url)
if (call_origin.scheme, call_origin.netloc) != (base_origin.scheme, base_origin.netloc):
raise FactoryMesError("接口目标越出 Factory MES 主机")
url = self._operation_url(spec, path)
try:
with self._client() as client:
response = client.request(

View File

@ -5,6 +5,7 @@ import os
import sys
import unittest
import uuid
from copy import deepcopy
from pathlib import Path
from unittest.mock import patch
@ -171,6 +172,85 @@ class FactoryOpenApiConnectorTests(unittest.TestCase):
self.assertEqual(kwargs["params"], {"page_size": 50})
self.assertEqual(result["data"]["count"], 1)
def test_swagger_base_path_is_added_to_operation_url(self):
from core.external_systems.factory import FactoryMesClient
spec = deepcopy(_SPEC)
spec["basePath"] = "/api"
spec["paths"] = {
path.removeprefix("/api"): value
for path, value in spec["paths"].items()
}
http = _Http()
client = FactoryMesClient("u", "p", _cfg())
with patch.object(client, "_client", return_value=http), patch.object(
client, "_fetch_spec", return_value=spec
):
client.call("qm_ftestwork_read", arguments={"batch": "B1"})
request = next(call for call in http.calls if call[0] == "GET")
self.assertEqual(request[1], "https://factory.invalid/api/qm/ftestwork/B1/")
def test_api_base_path_does_not_change_login_url(self):
from core.external_systems.factory import FactoryMesClient
http = _Http()
client = FactoryMesClient("u", "p", _cfg())
with patch.object(client, "_client", return_value=http):
client.authenticate()
request = next(call for call in http.calls if call[0] == "POST")
self.assertEqual(request[1], "https://factory.invalid/api/auth/token/")
def test_base_path_is_not_duplicated_when_operation_already_contains_it(self):
from core.external_systems.factory import FactoryMesClient, FactoryMesConfig
spec = {**deepcopy(_SPEC), "basePath": "/api"}
http = _Http()
client = FactoryMesClient("u", "p", _cfg())
with patch.object(client, "_client", return_value=http), patch.object(
client, "_fetch_spec", return_value=spec
):
client.call("qm_ftestwork_read", arguments={"batch": "B1"})
request = next(call for call in http.calls if call[0] == "GET")
self.assertNotIn("/api/api/", request[1])
configured_prefix = FactoryMesClient(
"u",
"p",
FactoryMesConfig(
**{**_cfg().__dict__, "base_url": "https://factory.invalid/api"}
),
)
self.assertEqual(
configured_prefix._operation_url(spec, "/api/qm/ftestwork/B1/"),
"https://factory.invalid/api/qm/ftestwork/B1/",
)
def test_openapi_server_path_is_used_but_cross_origin_server_is_rejected(self):
from core.external_systems.factory import FactoryMesClient, FactoryMesError
client = FactoryMesClient("u", "p", _cfg())
same_origin = {"openapi": "3.0.0", "servers": [{"url": "/v1"}], "paths": {}}
self.assertEqual(
client._operation_url(same_origin, "/quality/results/"),
"https://factory.invalid/v1/quality/results/",
)
cross_origin = {
"openapi": "3.0.0",
"servers": [{"url": "https://attacker.invalid/v1"}],
"paths": {},
}
with self.assertRaisesRegex(FactoryMesError, "越出 Factory MES 主机"):
client._operation_url(cross_origin, "/quality/results/")
def test_no_declared_base_path_keeps_existing_url_behavior(self):
from core.external_systems.factory import FactoryMesClient
client = FactoryMesClient("u", "p", _cfg())
self.assertEqual(
client._operation_url(_SPEC, "/api/qm/ftestwork/B1/"),
"https://factory.invalid/api/qm/ftestwork/B1/",
)
def test_post_is_denied_unless_admin_allowlists_operation(self):
from core.external_systems.factory import FactoryMesClient, FactoryMesError