diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1c229..9031459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 中的原有权限查询信息。账号密码加密保存且不会回显,接口默认只允许只读调用。 diff --git a/PROGRESS.md b/PROGRESS.md index 2c3f7cc..3896f0a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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、依赖或运行方式变化。 diff --git a/core/__init__.py b/core/__init__.py index 2f70720..3a72a5e 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.61.0" +__version__ = "0.61.1" diff --git a/core/external_systems/factory.py b/core/external_systems/factory.py index 0d10ecf..855c729 100644 --- a/core/external_systems/factory.py +++ b/core/external_systems/factory.py @@ -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( diff --git a/tests/test_external_systems.py b/tests/test_external_systems.py index b9dd61f..70c1f27 100644 --- a/tests/test_external_systems.py +++ b/tests/test_external_systems.py @@ -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