Files

377 lines
16 KiB
Python

"""登录态会话:持有已登录账号的 cookie,供加购与下单链路使用
与抓取链路(app/scraping 的 site_session / rakuma_session)不只是分模块,
而是分进程运行,原因:
- 抓取是**匿名**的,cookie 只用来过 Akamai 限速,丢了重新预热即可,无状态可言。
- 加购下单必须**带账号**,cookie 一旦失效需要重登——乐天登录有 reCAPTCHA 与设备
验证,全自动不可靠。本服务支持「失效时自动重登 + 撞验证码弹有头浏览器让人工接管」
的降级路径(依赖 account.yaml 与 relogin_enabled)。
- 这份登录态在整个系统里只能有一份。跟抓取同进程的话,抓取一扩容就会复制出 N 份
登录态与 N 个订单轮询,同一个账号被并发操作。
交易服务只管理乐天市场的购物车购买、支付与订单监控;ラクマ 抓取仍在抓取服务里
提供,但不进入交易链路。本模块只持有 rakuten 一个站点的登录态。
登录态来源是 Playwright 的 storage_state:
- 首次登录由 scripts/login.py 完成;
- 运行时失效且 relogin_enabled=true 时,本模块自动触发 login_runner.login_one()
重登(同账号串行,site 级 asyncio.Lock 防并发)。
账号密码不在本模块持有——try_relogin() 时从 account.yaml 临时读取,调用结束即
被 GC。日志只打 account.id,不打 username/password。
cookie 会被同时喂给两处:
- httpx 客户端 —— 加购这类纯表单提交走 HTTP 更快
- Playwright context —— 下单确认页有 JS 参与,必要时用浏览器走完
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
from app.shared.config import Settings
from app.shared.proxy import httpx_client_options
from app.shared.errors import NotLoggedInError, UpstreamRequestError
from app.trading.core import auth_site
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class AuthStatus:
"""一个站点的登录态快照"""
site: str
state_file_exists: bool
logged_in: bool | None # None 表示尚未探测过
checked_at: float | None = None
detail: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"site": self.site,
"state_file_exists": self.state_file_exists,
"logged_in": self.logged_in,
"checked_age_seconds": (
round(time.monotonic() - self.checked_at, 1) if self.checked_at else None
),
"detail": self.detail,
}
@dataclass(slots=True)
class _SiteAuth:
"""一个站点的登录通道:独立的 httpx 客户端与登录态缓存"""
name: str
client: httpx.AsyncClient
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
logged_in: bool | None = None
checked_at: float | None = None
detail: str = ""
class AuthSession:
"""持有登录态的会话
职责边界:只负责「有没有登录态、cookie 是什么、还有效吗」,
具体加购/下单的业务请求由各自的 client 组装后借这里的 HTTP 客户端发出。
"""
def __init__(self, settings: Settings):
self._settings = settings
self._sites: dict[str, _SiteAuth] = {}
# 自动重登的 site 级互斥锁:同账号同时只能一个登录流程(user_data_dir 被锁)
self._relogin_locks: dict[str, asyncio.Lock] = {}
# 每个 site 已完成的重登尝试计数。用途只有一个:让在锁上排队的调用方能分辨
# 「我等的这段时间里已经有人替我登过了」。不能用 status().logged_in 代替——
# 重登成功后的 reload() 会把 logged_in 重置成 None(未探测),排队者读到
# None 会误判成「还没人登过」,于是 N 个并发调用串行触发 N 次真实登录。
self._relogin_epochs: dict[str, int] = {}
# ---- 生命周期 ----
async def start(self) -> None:
"""为站点创建带登录 cookie 的 HTTP 客户端
登录态文件不存在时同样创建客户端(只是没有 cookie),这样 /health 与
/api/auth/status 能如实回报「未登录」,而不是整个服务起不来。
"""
for name, profile in auth_site.PROFILES.items():
if name in self._sites:
continue
client = httpx.AsyncClient(
headers=profile.headers(),
timeout=self._settings.request_timeout_seconds,
follow_redirects=True,
**httpx_client_options(self._settings),
http2=True,
)
self._sites[name] = _SiteAuth(name=name, client=client)
loaded = self._load_cookies(name)
logger.info("登录通道已就绪:site=%s cookies=%s", name, loaded)
async def close(self) -> None:
for auth in self._sites.values():
try:
await auth.client.aclose()
except Exception:
logger.debug("关闭登录 HTTP 客户端失败:site=%s", auth.name, exc_info=True)
self._sites.clear()
# ---- 登录态文件 ----
def state_path(self, site: str) -> Path:
"""某站点 storage_state 文件的落盘路径"""
return self._settings.auth_state_path / auth_site.profile(site).state_filename
def _load_cookies(self, site: str) -> int:
"""把 storage_state 里的 cookie 灌进该站的 httpx 客户端,返回条数"""
path = self.state_path(site)
if not path.exists():
return 0
try:
state = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("登录态文件读取失败:site=%s path=%s err=%s", site, path, exc)
return 0
auth = self._sites[site]
auth.client.cookies.clear()
count = 0
for cookie in state.get("cookies", []):
name = cookie.get("name")
value = cookie.get("value")
if not name or value is None:
continue
auth.client.cookies.set(
name,
value,
domain=cookie.get("domain") or "",
path=cookie.get("path") or "/",
)
count += 1
return count
def reload(self, site: str) -> int:
"""重新从磁盘加载登录态(人工登录完成后调用),返回 cookie 条数"""
auth = self._sites.get(site)
if auth is None:
raise ValueError(f"未知站点:{site}")
count = self._load_cookies(site)
auth.logged_in = None
auth.checked_at = None
auth.detail = "已重新加载登录态,尚未探测"
logger.info("登录态已重新加载:site=%s cookies=%s", site, count)
return count
# ---- 登录态探测 ----
async def check(self, site: str) -> AuthStatus:
"""探测某站登录态是否仍然有效
每次都真实打一次请求——登录态过期没有可靠的本地判据(cookie 的 expires
与服务端会话不是一回事),只能问站点。
"""
auth = self._require_site(site)
async with auth.lock:
try:
if site != "rakuten":
raise ValueError(f"未知站点:{site}")
logged_in, detail = await self._check_rakuten(auth)
except httpx.HTTPError as exc:
raise UpstreamRequestError(
f"登录态探测请求失败:site={site} err={type(exc).__name__}: {exc}"
) from exc
auth.logged_in = logged_in
auth.checked_at = time.monotonic()
auth.detail = detail
logger.info("登录态探测:site=%s logged_in=%s detail=%s", site, logged_in, detail)
return self.status(site)
async def _check_rakuten(self, auth: _SiteAuth) -> tuple[bool, str]:
"""购物车页含「現在ログインしていません」即未登录"""
response = await auth.client.get(auth_site.PROFILES["rakuten"].probe_url)
if response.status_code >= 400:
return False, f"购物车页返回 status {response.status_code}"
if not auth_site.is_logged_in(
"rakuten", final_url=str(response.url), body=response.text
):
return False, "购物车页显示未登录"
return True, "购物车页未出现未登录标记"
async def require_logged_in(self, site: str) -> None:
"""确保某站处于登录态,否则抛错
加购与下单前的统一入口。失效时的处理顺序:
1. 检测到未登录 → 若 relogin_enabled,尝试 try_relogin(site)
2. 重登成功 → 重新 check 一次,登录态转好即放行
3. 重登失败 / 未启用 / account.yaml 缺失 → 抛 NotLoggedInError 让 worker 转 needs_human
**写**操作(加购 / 提交订单 / 付款)只在这一层(动作前置检查)触发重登;
执行过程中失效不重试,避免脏状态(cart 已提交但响应后 cookie 失效等场景)。
**只读**操作(订单列表 / 订单详情查询)另有一层执行中重试,见
`site_interact.SiteInteractor._read_with_relogin_retry`——重跑一次查询没有
副作用,这条边界是刻意区分的,不要把它推广到写操作上。
"""
status = await self.check(site)
if status.logged_in:
return
# 尝试自动重登(relogin_enabled=False 或无 account.yaml 时返回 False)
if await self.try_relogin(site):
status = await self.check(site)
if status.logged_in:
return
detail = f"重登后仍判定未登录:{status.detail}"
else:
detail = status.detail
raise NotLoggedInError(site=site, detail=detail)
# ---- 自动重登 ----
async def try_relogin(self, site: str) -> bool:
"""触发自动重登:读 account.yaml 默认账号 → 跑 login_one → reload cookie
- relogin_enabled=False 直接返回 False(不抛错,让调用方走原降级路径)
- account.yaml 缺失或该站没账号 → 返回 False,仅记日志
- 同 site 串行(asyncio.Lock),并发请求只跑一次登录流程
- 账号密码不进日志:只打 account.id;login_runner 内部完成填表
成功返回前已完成 self.reload(site),httpx 客户端拿到新 cookie;
Playwright 侧(SiteInteractor)通过 storage_state 文件 mtime 自检刷新。
"""
if not self._settings.relogin_enabled:
return False
# site 级锁:同账号同 user_data_dir,并发重登会撞锁
lock = self._relogin_locks.setdefault(site, asyncio.Lock())
epoch_before_wait = self._relogin_epochs.get(site, 0)
async with lock:
# 在锁上等过、且期间有人跑完了一轮重登 → 大概率不需要再登一次。
# 这里必须**真探测**而不是读 self.status():重登成功后的 reload() 把
# logged_in 重置成 None,读缓存会一律判「还没登上」,白跑一次 login_one
# (最坏 N 个并发调用串行触发 N 次真实登录,各自最长 relogin_timeout)。
if self._relogin_epochs.get(site, 0) != epoch_before_wait:
# 探测失败(网络问题)时不在这里抛错——try_relogin 的契约是「降级
# 返回 False / 继续走登录」,不是抛异常;这种情况直接落到下面正常
# 跑一次 login_one(它自己也会先探测登录态,已登录就跳过填表)。
try:
status = await self.check(site)
except UpstreamRequestError:
logger.warning(
"自动重登:site=%s 等锁后复核登录态失败,继续尝试登录", site, exc_info=True
)
else:
if status.logged_in:
logger.info("自动重登跳过:site=%s 等锁期间已由其他调用方登录完成", site)
return True
# 刚有人登过还是没登上,说明这轮站点侧就是登不上(验证码/密码错/
# 风控)。同一波并发里再串一遍只是把每个调用方各卡一个
# relogin_timeout,站点侧结果不会变——直接失败,交人工。
logger.warning(
"自动重登跳过:site=%s 等锁期间刚失败过一次,不在同一波并发里重复触发",
site,
)
return False
# 读 account.yaml(失败时降级,不抛错)
try:
from app.trading.services import login_runner
accounts_by_site = login_runner.load_accounts()
except FileNotFoundError as exc:
logger.warning("自动重登跳过:%s", exc)
return False
except ValueError as exc:
logger.warning("自动重登跳过:account.yaml 格式错误:%s", exc)
return False
account = login_runner.default_account_for(site, accounts_by_site)
if account is None:
logger.warning("自动重登跳过:account.yaml 没有 %s 的账号", site)
return False
logger.info(
"自动重登启动:site=%s account_id=%s timeout=%ss",
site, account.id, self._settings.relogin_timeout_seconds,
)
try:
ok = await login_runner.login_one(
account,
self._settings,
timeout_seconds=self._settings.relogin_timeout_seconds,
progress=None, # 用 logger,不打到 stdout
)
except Exception:
logger.exception("自动重登异常:site=%s account_id=%s", site, account.id)
return False
finally:
# 成败都记一次「本站跑过一轮登录流程」。放 finally 是因为排队者要
# 分辨的是「有没有人替我尝试过」,失败的尝试同样算——否则失败后
# 排队的调用方会一个接一个重跑同一个注定失败的登录。
self._relogin_epochs[site] = self._relogin_epochs.get(site, 0) + 1
if not ok:
logger.warning("自动重登失败:site=%s account_id=%s", site, account.id)
return False
# 重登成功:reload cookie 到 httpx 客户端
count = self.reload(site)
logger.info(
"自动重登成功:site=%s account_id=%s cookies=%s",
site, account.id, count,
)
return True
# ---- 对外访问 ----
@property
def sites(self) -> tuple[str, ...]:
"""已初始化的站点名"""
return tuple(self._sites)
def client(self, site: str) -> httpx.AsyncClient:
"""取该站带登录 cookie 的 HTTP 客户端"""
return self._require_site(site).client
def cookies_for_browser(self, site: str) -> list[dict[str, Any]]:
"""导出 cookie 供 Playwright context 使用"""
path = self.state_path(site)
if not path.exists():
return []
try:
state = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
return list(state.get("cookies", []))
def status(self, site: str) -> AuthStatus:
"""该站登录态快照(不触发探测,用缓存结果)"""
auth = self._require_site(site)
return AuthStatus(
site=site,
state_file_exists=self.state_path(site).exists(),
logged_in=auth.logged_in,
checked_at=auth.checked_at,
detail=auth.detail,
)
def status_all(self) -> dict[str, dict[str, Any]]:
"""登录态快照,供健康检查展示"""
return {name: self.status(name).to_dict() for name in self._sites}
def _require_site(self, site: str) -> _SiteAuth:
auth = self._sites.get(site)
if auth is None:
raise ValueError(f"未知站点或登录会话未初始化:{site}")
return auth