"""架构测试:守住三个部署单元的依赖方向 三个进程之后,最容易悄悄退化的不是功能而是边界——某天为了省事在交易侧 `from app.scraping.parsers...` 一句,两个服务就重新长回一起;或在 worker 里 `from app.gateway.models...` 一句,本地的零依赖网关假设就破了。 允许的方向只有三条:scraping → shared、trading → shared、gateway → shared。 任何两侧之间互不 import;需要对方能力时走 HTTP 接口。 """ from __future__ import annotations import ast from pathlib import Path import pytest APP_DIR = Path(__file__).resolve().parent.parent / "app" def _imported_modules(path: Path) -> set[str]: """取一个源文件里 import 到的模块名(只看真实 import,不看注释与文档字符串)""" tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) modules: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.Import): modules.update(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: modules.add(node.module) return modules def _python_files(package: str) -> list[Path]: return sorted((APP_DIR / package).rglob("*.py")) @pytest.mark.parametrize( ("package", "forbidden"), [ # 三个部署单元互不 import ("scraping", "app.trading"), ("scraping", "app.gateway"), ("trading", "app.scraping"), ("trading", "app.gateway"), ("gateway", "app.scraping"), ("gateway", "app.trading"), # shared 是三方的共同底座,反向依赖任何一侧都会形成环 ("shared", "app.scraping"), ("shared", "app.trading"), ("shared", "app.gateway"), ], ) def test_package_does_not_import(package: str, forbidden: str): offenders = [ f"{path.relative_to(APP_DIR)} -> {module}" for path in _python_files(package) for module in _imported_modules(path) if module == forbidden or module.startswith(f"{forbidden}.") ] assert not offenders, f"{package} 不应依赖 {forbidden}:{offenders}" def test_all_three_entrypoints_build(): """三个入口都要能独立创建应用——这是「三个部署单元」的最低验收""" from app.gateway.main import create_app as create_gateway_app from app.scraping.main import create_app as create_scraping_app from app.trading.main import create_app as create_trading_app # 用 OpenAPI 里的路径而不是 app.routes:新版 FastAPI 把 include_router 的结果 # 包成 _IncludedRouter 而非摊平,OpenAPI 反映的才是真正对外暴露的契约 scraping_paths = set(create_scraping_app().openapi()["paths"]) trading_paths = set(create_trading_app().openapi()["paths"]) gateway_paths = set(create_gateway_app().openapi()["paths"]) # 各自的标志性接口都在 assert "/api/search" in scraping_paths assert "/api/auth/status" in trading_paths assert "/api/orders" in gateway_paths assert "/api/orders/lease" in gateway_paths # 登录态接口不该出现在抓取/网关服务上:抓取实例可以多开、网关在服务器上暴露, # 多份登录态就是重复下单的温床 assert not any(path.startswith("/api/auth") for path in scraping_paths) assert not any(path.startswith("/api/auth") for path in gateway_paths) # 抓取的站点接口不应出现在交易/网关上 assert not any(path.startswith("/api/rakuma") for path in trading_paths) assert not any(path.startswith("/api/rakuma") for path in gateway_paths) assert not any(path.startswith("/api/search") for path in trading_paths) assert not any(path.startswith("/api/search") for path in gateway_paths) # 下单任务接口只属于网关:trading 是本地 worker,不接收外部下单意图 assert not any(path.startswith("/api/orders") for path in scraping_paths) assert not any(path.startswith("/api/orders") for path in trading_paths) # /health 三个服务都有,各报各的 assert "/health" in scraping_paths assert "/health" in trading_paths assert "/health" in gateway_paths