把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 N 份登录态与 N 个轮询, 同一账号会被并发操作。 - app/shared:配置、错误码、日志、ApiResponse 信封 + Bearer 鉴权 + 异常处理器、 导航请求头构造器 - app/scraping:站点常量、会话、解析器与 10 个抓取接口,:31107,可多开 - app/trading:登录态查询/重载与健康检查,:31108,只能单实例 - 依赖方向锁为 scraping→shared、trading→shared,两侧互不 import; tests/test_architecture.py 用 AST 检查 import 并校验两个 app 的路径不串 - 登录态 UA 在 trading 独立持有:与抓取 UA 值相同但变更理由不同,抓取 UA 为绕 反爬可随时调整,登录 UA 一改可能触发设备校验使已落盘 cookie 失效 - scripts/login.py 与 AuthSession 共用 auth_site.PROFILES 与 is_logged_in,判据只写一遍 - 同一镜像两个启动命令,交易容器覆盖 command 并设 RAKUTEN_HEALTH_PORT 同时带上此前未提交的 ラクマ 分类接口与登录态基础设施。 验证:239 个离线用例全绿;两个入口真实启动,/health 与鉴权正常。 未验证:真实探测登录态(当前开发机无外网,对站点的连接全部超时)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
74 lines
3.1 KiB
Python
74 lines
3.1 KiB
Python
"""架构测试:守住抓取侧与交易侧的依赖方向
|
|
|
|
拆成两个进程之后,最容易悄悄退化的不是功能而是边界——某天为了省事在交易侧
|
|
`from app.scraping.parsers...` 一句,两个服务就重新长回一起:抓取的解析改动会
|
|
牵动下单链路,交易服务也被迫加载整套抓取依赖(包括 Playwright)。
|
|
|
|
允许的方向只有两条:scraping → shared、trading → shared。
|
|
交易侧要用抓取的能力,走抓取服务的 HTTP 接口(`purchase` 块本来就是它的对外契约),
|
|
不直接 import。
|
|
"""
|
|
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"),
|
|
[
|
|
("scraping", "app.trading"),
|
|
("trading", "app.scraping"),
|
|
# shared 是两侧的共同底座,反向依赖任何一侧都会形成环
|
|
("shared", "app.scraping"),
|
|
("shared", "app.trading"),
|
|
],
|
|
)
|
|
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_both_entrypoints_build():
|
|
"""两个入口都要能独立创建应用——这是「两个部署单元」的最低验收"""
|
|
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"])
|
|
|
|
assert "/api/search" in scraping_paths
|
|
assert "/api/auth/status" in trading_paths
|
|
# 登录态接口不该出现在抓取服务上:抓取实例可以多开,多份登录态就是重复下单的温床
|
|
assert not any(path.startswith("/api/auth") for path in scraping_paths)
|
|
assert not any(path.startswith("/api/rakuma") for path in trading_paths)
|
|
# /health 两边都有,各报各的
|
|
assert "/health" in scraping_paths and "/health" in trading_paths
|