fix(external-systems): 修复 MES 接口路径前缀
This commit is contained in:
parent
cb9a39f33d
commit
c489210b2a
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.61.1 — 2026-08-04
|
||||||
|
|
||||||
|
- 修复部分 MES 已成功连接、也能搜索接口,但实际查询始终返回 404 的问题;现在会自动识别接口规范声明的 `/api`、`/v1` 等业务路径前缀,无需修改现有连接配置。
|
||||||
|
|
||||||
## 0.61.0 — 2026-08-04
|
## 0.61.0 — 2026-08-04
|
||||||
|
|
||||||
- 新增“外部系统”能力:管理员可以在管理后台配置可信的 Factory MES、选择对全部用户或指定用户开放;获权用户在工作台填写自己的 MES 账号后,即可让助手按本人在 MES 中的原有权限查询信息。账号密码加密保存且不会回显,接口默认只允许只读调用。
|
- 新增“外部系统”能力:管理员可以在管理后台配置可信的 Factory MES、选择对全部用户或指定用户开放;获权用户在工作台填写自己的 MES 账号后,即可让助手按本人在 MES 中的原有权限查询信息。账号密码加密保存且不会回显,接口默认只允许只读调用。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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.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、依赖或运行方式变化。
|
- **08-04 / 0.60.29 / PPT 总院红候选硬门 + 别名直达**:新建 PPT 未说明所属机构时,模板 / 品牌 / visual_style 候选必须包含 `zongyuan_red`;明确外部机构或其他唯一模板时不强制加入。总院、中国建材、中建材、建材集团、CNBM 等“机构词 × 模板/风格词”表达直接归一到总院红,不再追问具体模板;删除易混淆的通用 `business-red` 品牌预设,普通商务红仅保留为即时配色候选。新增路由回归测试,相关 5 项 unittest、品牌索引 JSON 校验及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.61.0"
|
__version__ = "0.61.1"
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,70 @@ class FactoryMesClient:
|
||||||
})
|
})
|
||||||
return results
|
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]:
|
def test_connection(self) -> dict[str, Any]:
|
||||||
token = self.authenticate()
|
token = self.authenticate()
|
||||||
spec = self._fetch_spec(token)
|
spec = self._fetch_spec(token)
|
||||||
|
|
@ -257,11 +321,7 @@ class FactoryMesClient:
|
||||||
if "{" in path or "}" in path:
|
if "{" in path or "}" in path:
|
||||||
raise FactoryMesError("路径参数未完整提供")
|
raise FactoryMesError("路径参数未完整提供")
|
||||||
|
|
||||||
url = urljoin(self.cfg.base_url + "/", path.lstrip("/"))
|
url = self._operation_url(spec, path)
|
||||||
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 主机")
|
|
||||||
try:
|
try:
|
||||||
with self._client() as client:
|
with self._client() as client:
|
||||||
response = client.request(
|
response = client.request(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
import uuid
|
import uuid
|
||||||
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
@ -171,6 +172,85 @@ class FactoryOpenApiConnectorTests(unittest.TestCase):
|
||||||
self.assertEqual(kwargs["params"], {"page_size": 50})
|
self.assertEqual(kwargs["params"], {"page_size": 50})
|
||||||
self.assertEqual(result["data"]["count"], 1)
|
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):
|
def test_post_is_denied_unless_admin_allowlists_operation(self):
|
||||||
from core.external_systems.factory import FactoryMesClient, FactoryMesError
|
from core.external_systems.factory import FactoryMesClient, FactoryMesError
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue