自动重登修两处:并发去重读错缓存、只读订单查询掉登录被静默吞掉

- try_relogin 的并发去重原本读 status().logged_in,但重登成功后的 reload()
  会把它重置成 None,排队在 site 锁上的调用方一律判「还没人登上」,N 个并发
  调用会串行触发 N 次真实登录(各自最长 relogin_timeout)。改用 _relogin_epochs
  计数:等锁期间 epoch 变过就真探测一次,已登录即跳过;仍未登录说明这轮站点侧
  就是登不上(验证码/密码错/风控),直接失败,不在同一波并发里重复触发。
  原并发测试的桩自相矛盾(login_one 返回成功、探针页始终回未登录),断言只能
  松到 count >= 1;桩改为登录成功时翻转探针页,断言收紧到 count == 1。

- check_order_status / list_recent_orders 执行中掉登录此前会被静默吞掉:订单页
  被踢到 SSO 后既不报错也没订单号,_parse_order_status 返回 found=False 被
  _monitor_order 当成「订单还没反映出来」继续轮询(默认 3 小时一轮),
  _parse_order_list 则退化成空列表让 verify_on_site 转 unknown 卡住等人工。
  新增 SiteInteractor._read_with_relogin_retry 外壳:只读操作中途判定掉登录时
  重登一次并整个重跑,第二次仍失败抛 NotLoggedInError。只给读操作用——写操作
  中途掉登录不能重跑(上次动作可能已在站点侧生效),这条边界在两边文档里写明。

- 判据是新增的 auth_site.looks_logged_out:与探针页上权威的 is_logged_in 分开,
  它是业务页上的单边启发式(返回 False 不代表登录着),只用于「判错最多多花一次
  重登」的重试决策。刻意排除 session/upgrade——那是已登录时的站点风控复核密码,
  不是 cookie 过期,误判会把风控当掉登录去重登。

判据里「掉登录会跳到 SSO 域」这一步没有真实探测证据(要复现得先让一份真实登录态
过期),是按站点通行行为的推断,已在常量注释标注;新增测试用替身页面,不是真实
站点 HTML。399 测试全绿(仓库未配 ruff/flake8/mypy,只跑了 pytest)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 14:05:47 +08:00
co-authored by Claude Opus 5
parent 295fc7ad69
commit e2875f0c00
5 changed files with 480 additions and 27 deletions
+42 -6
View File
@@ -89,6 +89,11 @@ class AuthSession:
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] = {}
# ---- 生命周期 ----
@@ -211,8 +216,11 @@ class AuthSession:
2. 重登成功 → 重新 check 一次,登录态转好即放行
3. 重登失败 / 未启用 / account.yaml 缺失 → 抛 NotLoggedInError 让 worker 转 needs_human
重登只在这一层(任务前置检查)触发;任务执行过程中失效不重试,
避免脏状态(cart 已提交但响应后 cookie 失效等场景)。
**写**操作(加购 / 提交订单 / 付款)只在这一层(动作前置检查)触发重登
执行过程中失效不重试,避免脏状态(cart 已提交但响应后 cookie 失效等场景)。
**只读**操作(订单列表 / 订单详情查询)另有一层执行中重试,见
`site_interact.SiteInteractor._read_with_relogin_retry`——重跑一次查询没有
副作用,这条边界是刻意区分的,不要把它推广到写操作上。
"""
status = await self.check(site)
if status.logged_in:
@@ -246,11 +254,34 @@ class AuthSession:
# site 级锁:同账号同 user_data_dir,并发重登会撞锁
lock = self._relogin_locks.setdefault(site, asyncio.Lock())
epoch_before_wait = self._relogin_epochs.get(site, 0)
async with lock:
# 拿锁后再 check 一次——可能别的协程刚重登过
status = self.status(site)
if status.logged_in:
return True
# 在锁上等过、且期间有人跑完了一轮重登 → 大概率不需要再登一次。
# 这里必须**真探测**而不是读 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:
@@ -282,6 +313,11 @@ class AuthSession:
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)