拆分抓取与交易服务

把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」,
而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、
订单监控是常驻轮询——同进程时抓取一扩容就会复制出 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:
2026-07-27 15:05:01 +08:00
co-authored by Claude Opus 5
parent 4250388762
commit 104d7fef6b
80 changed files with 2330 additions and 402 deletions
+157
View File
@@ -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"]