拆分抓取与交易服务
把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 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>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""交易服务 API 测试:健康检查、登录态查询与重载、鉴权
|
||||
|
||||
登录态会话被替换为桩,不触达真实站点、不需要真实账号。
|
||||
AuthSession 自身的行为(cookie 加载、探测判据)在 tests/test_auth_session.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.errors import NotLoggedInError
|
||||
from app.trading.main import create_app
|
||||
from app.trading.services.auth_session import AuthStatus
|
||||
|
||||
TOKEN = get_settings().bearer_token
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
class StubAuthSession:
|
||||
"""记录调用并返回固定登录态的桩"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.checked: list[str] = []
|
||||
self.reloaded: list[str] = []
|
||||
self.logged_in = True
|
||||
|
||||
@property
|
||||
def sites(self) -> tuple[str, ...]:
|
||||
return ("rakuten", "rakuma")
|
||||
|
||||
def _status(self, site: str) -> AuthStatus:
|
||||
return AuthStatus(
|
||||
site=site,
|
||||
state_file_exists=True,
|
||||
logged_in=self.logged_in,
|
||||
checked_at=None,
|
||||
detail="stub",
|
||||
)
|
||||
|
||||
async def check(self, site: str) -> AuthStatus:
|
||||
self.checked.append(site)
|
||||
return self._status(site)
|
||||
|
||||
def status(self, site: str) -> AuthStatus:
|
||||
return self._status(site)
|
||||
|
||||
def status_all(self) -> dict[str, dict]:
|
||||
return {site: self._status(site).to_dict() for site in self.sites}
|
||||
|
||||
def reload(self, site: str) -> int:
|
||||
self.reloaded.append(site)
|
||||
return 3
|
||||
|
||||
async def require_logged_in(self, site: str) -> None:
|
||||
if not self.logged_in:
|
||||
raise NotLoggedInError(site=site, detail="stub")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""lifespan 收尾会调用;桩没有真实客户端要关"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_and_stub():
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
stub = StubAuthSession()
|
||||
app.state.container.auth_session = stub
|
||||
yield client, stub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(client_and_stub):
|
||||
return client_and_stub[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub(client_and_stub):
|
||||
return client_and_stub[1]
|
||||
|
||||
|
||||
# ---- 健康检查 ----
|
||||
|
||||
|
||||
def test_health_needs_no_token(client):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["data"]["status"] == "ok"
|
||||
assert set(body["data"]["auth"]) == {"rakuten", "rakuma"}
|
||||
|
||||
|
||||
def test_health_does_not_probe_the_site(client, stub):
|
||||
"""健康检查只读缓存:它会被高频轮询,不能每次都去打站点"""
|
||||
client.get("/health")
|
||||
assert stub.checked == []
|
||||
|
||||
|
||||
# ---- 鉴权 ----
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/auth/status", "/api/auth/reload"])
|
||||
def test_auth_endpoints_reject_missing_token(client, path):
|
||||
response = client.post(path, json={})
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == 1001
|
||||
|
||||
|
||||
def test_auth_endpoints_reject_wrong_token(client):
|
||||
response = client.post(
|
||||
"/api/auth/status", json={}, headers={"Authorization": "Bearer wrong-token"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---- 登录态查询 ----
|
||||
|
||||
|
||||
def test_status_probes_both_sites_by_default(client, stub):
|
||||
response = client.post("/api/auth/status", json={}, headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert [item["site"] for item in body["data"]["sites"]] == ["rakuten", "rakuma"]
|
||||
assert stub.checked == ["rakuten", "rakuma"]
|
||||
|
||||
|
||||
def test_status_can_skip_the_probe(client, stub):
|
||||
"""refresh=false 时读缓存,不打站点"""
|
||||
response = client.post("/api/auth/status", json={"refresh": False}, headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert stub.checked == []
|
||||
|
||||
|
||||
def test_status_accepts_a_single_site(client, stub):
|
||||
response = client.post("/api/auth/status", json={"site": "rakuma"}, headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert stub.checked == ["rakuma"]
|
||||
|
||||
|
||||
def test_status_rejects_unknown_site(client):
|
||||
"""站点名是枚举,未知值应在校验层就被挡下"""
|
||||
response = client.post("/api/auth/status", json={"site": "mercari"}, headers=AUTH)
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == 1002
|
||||
|
||||
|
||||
# ---- 登录态重载 ----
|
||||
|
||||
|
||||
def test_reload_reloads_then_probes(client, stub):
|
||||
response = client.post("/api/auth/reload", json={"site": "rakuten"}, headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["data"]["reloaded"] == {"rakuten": 3}
|
||||
# 重载后必须立刻探测一次,否则调用方拿不到「这次登录到底成没成」
|
||||
assert stub.reloaded == ["rakuten"]
|
||||
assert stub.checked == ["rakuten"]
|
||||
Reference in New Issue
Block a user