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

- 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
+238 -1
View File
@@ -17,7 +17,13 @@ import pytest
from datetime import datetime, timezone
from app.shared.errors import CartOperationError, InvalidRequestError, OrderOperationError
from app.shared.errors import (
CartOperationError,
InvalidRequestError,
NotLoggedInError,
OrderOperationError,
)
from app.trading.core import auth_site
from app.trading.worker.models import LeaseTask
from app.shared.task_state import OrderState
from app.trading.worker.site_interact import (
@@ -337,6 +343,237 @@ async def test_check_order_status_without_start_raises():
await site.check_order_status(_REAL_ORDER_ID)
# ---- auth_site.looks_logged_out:业务页上的单边「像是掉登录」判据 ----
def test_looks_logged_out_detects_sso_redirect():
"""订单页落地到 SSO 域 → 判为像掉登录"""
assert auth_site.looks_logged_out(
"rakuten",
final_url="https://login.account.rakuten.com/sso/authorize?client_id=x",
body="<html>ログイン</html>",
)
def test_looks_logged_out_ignores_session_upgrade():
"""session upgrade 是「已登录但要求复核密码」的站点风控,不是掉登录
误判会把风控拦截当 cookie 过期,白跑一次重登还掩盖真实原因。
"""
assert not auth_site.looks_logged_out(
"rakuten",
final_url="https://login.account.rakuten.com/sign_in/session/upgrade?client_id=x",
body="<html>パスワードを入力</html>",
)
def test_looks_logged_out_detects_legacy_marker_on_business_page():
"""业务页正文出现旧版未登录 marker(SSR 落地 HTML)→ 判为像掉登录"""
assert auth_site.looks_logged_out(
"rakuten",
final_url="https://order.my.rakuten.co.jp/purchase-history/order-list",
body=f"<html>{auth_site.RAKUTEN_LOGGED_OUT_MARKER}</html>",
)
def test_looks_logged_out_false_on_normal_order_page():
"""正常订单页没有任何未登录信号 → False(注意这**不**等于「确认登录着」)"""
assert not auth_site.looks_logged_out(
"rakuten",
final_url="https://order.my.rakuten.co.jp/purchase-history/order-list",
body=_stepper_html(active_stage="出荷"),
)
def test_looks_logged_out_rejects_unknown_site():
with pytest.raises(ValueError):
auth_site.looks_logged_out("rakuma", final_url="https://x/", body="")
# ---- 只读操作的「执行中掉登录 → 重登一次 → 重跑」----
#
# 用最小替身顶掉 Playwright(_FakeContext/_FakeOrderPage)与 AuthSession
# (_FakeAuthSession),测的是 SiteInteractor 自己的重试编排:什么时候判掉登录、
# 重登几次、重登失败怎么收场。页面解析由上面那批 _parse_* 纯函数测试覆盖。
class _FakeOrderPage:
"""只实现 goto/content/url/close 的 page 替身;每次 goto 按脚本换一份响应"""
def __init__(self, responses: list[tuple[str, str]]):
# responses: [(final_url, html), ...],按 goto 次序消费,用尽后重复最后一份
self._responses = responses
self.goto_urls: list[str] = []
self.url = ""
self._html = ""
self.closed = False
async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url)
idx = min(len(self.goto_urls) - 1, len(self._responses) - 1)
self.url, self._html = self._responses[idx]
async def wait_for_timeout(self, ms: int) -> None:
return None
async def content(self) -> str:
return self._html
async def close(self) -> None:
self.closed = True
class _FakeContext:
"""new_page() 按脚本发页面;页面用尽则复用最后一个"""
def __init__(self, pages: list[_FakeOrderPage]):
self._pages = pages
self.handed_out: list[_FakeOrderPage] = []
async def new_page(self) -> _FakeOrderPage:
page = self._pages[min(len(self.handed_out), len(self._pages) - 1)]
self.handed_out.append(page)
return page
class _FakeAuthSession:
"""记录 require_logged_in / try_relogin 调用次数的 AuthSession 替身"""
def __init__(self, *, relogin_ok: bool = True):
self.relogin_ok = relogin_ok
self.require_calls = 0
self.relogin_calls = 0
async def require_logged_in(self, site: str) -> None:
self.require_calls += 1
async def try_relogin(self, site: str) -> bool:
self.relogin_calls += 1
return self.relogin_ok
_SSO_URL = "https://login.account.rakuten.com/sso/authorize?client_id=x"
_ORDER_DETAIL_LANDED_URL = "https://order.my.rakuten.co.jp/purchase-history/?order_number=x"
_ORDER_LIST_LANDED_URL = "https://order.my.rakuten.co.jp/purchase-history/order-list"
def _build_site(tmp_path: Path, context: _FakeContext, auth: _FakeAuthSession) -> SiteInteractor:
"""装配一个不依赖 Playwright 的 SiteInteractor
auth_state_dir 指向空目录:_refresh_context_if_stale 见不到 storage_state
文件就直接返回,不会试图重建 context(重建需要真实 browser)。
"""
from app.shared.config import Settings
site = SiteInteractor(
auth_session=auth, # type: ignore[arg-type]
settings=Settings(auth_state_dir=str(tmp_path / "auth"), evidence_dir=str(tmp_path)),
)
site._context = context # type: ignore[assignment]
return site
async def test_check_order_status_relogins_and_retries_when_kicked_to_sso(tmp_path):
"""详情页被踢到 SSO → 重登一次 → 重跑拿到真实快照"""
pages = [
_FakeOrderPage([(_SSO_URL, "<html>ログイン</html>")]),
_FakeOrderPage([(_ORDER_DETAIL_LANDED_URL, _stepper_html(active_stage="出荷"))]),
]
auth = _FakeAuthSession(relogin_ok=True)
site = _build_site(tmp_path, _FakeContext(pages), auth)
snapshot = await site.check_order_status(_REAL_ORDER_ID)
assert snapshot.found is True
assert snapshot.order_state == OrderState.SHIPPED
assert auth.relogin_calls == 1
assert auth.require_calls == 2 # 每次 attempt 前都过一遍前置检查
assert all(p.closed for p in pages) # 两次 attempt 的 page 都关掉了
async def test_check_order_status_raises_not_logged_in_when_relogin_fails(tmp_path):
"""重登失败 → 抛 NotLoggedInError,而不是把「被踢走」伪装成 found=False"""
page = _FakeOrderPage([(_SSO_URL, "<html>ログイン</html>")])
auth = _FakeAuthSession(relogin_ok=False)
site = _build_site(tmp_path, _FakeContext([page]), auth)
with pytest.raises(NotLoggedInError):
await site.check_order_status(_REAL_ORDER_ID)
assert auth.relogin_calls == 1
async def test_check_order_status_raises_not_logged_in_when_still_kicked_after_relogin(tmp_path):
"""重登「成功」了但页面还是被踢走 → 只重试一次就抛错,不无限重登"""
page = _FakeOrderPage([(_SSO_URL, "<html>ログイン</html>")])
auth = _FakeAuthSession(relogin_ok=True)
site = _build_site(tmp_path, _FakeContext([page]), auth)
with pytest.raises(NotLoggedInError):
await site.check_order_status(_REAL_ORDER_ID)
assert auth.relogin_calls == 1
assert len(page.goto_urls) == 2 # 只跑了两轮
async def test_check_order_status_order_not_yet_visible_is_not_treated_as_logged_out(tmp_path):
"""订单号还没反映到详情页(站点自己说要等 10 分钟)不是掉登录:不重登,返回 found=False"""
page = _FakeOrderPage([
(_ORDER_DETAIL_LANDED_URL, "<html>ご注文の反映に10分ほどかかります</html>")
])
auth = _FakeAuthSession(relogin_ok=True)
site = _build_site(tmp_path, _FakeContext([page]), auth)
snapshot = await site.check_order_status(_REAL_ORDER_ID)
assert snapshot.found is False
assert auth.relogin_calls == 0
async def test_list_recent_orders_relogins_and_retries_from_first_page(tmp_path):
"""列表页第一页拿不到结构化数据且像掉登录 → 重登后从 page 1 重跑"""
logged_out_page = _FakeOrderPage([(_SSO_URL, "<html>ログイン</html>")])
good_html = _wrap_state(_real_order_list_state())
good_page = _FakeOrderPage([(_ORDER_LIST_LANDED_URL, good_html)])
auth = _FakeAuthSession(relogin_ok=True)
site = _build_site(tmp_path, _FakeContext([logged_out_page, good_page]), auth)
window = await site.list_recent_orders(since=datetime(2026, 8, 1, tzinfo=timezone.utc))
assert [e.order_number for e in window.entries] == [_REAL_ORDER_ID]
assert window.window_fully_covered is True
assert auth.relogin_calls == 1
# 重跑确实是从第一页开始,不是接着上次的页码
assert good_page.goto_urls[0].endswith("/order-list")
async def test_list_recent_orders_page_structure_change_is_not_treated_as_logged_out(tmp_path):
"""第一页拿不到 ph-list 但没有任何未登录信号(页面改版)→ 不重登,按原路径转「没覆盖完」"""
page = _FakeOrderPage([(_ORDER_LIST_LANDED_URL, "<html><body>改版了</body></html>")])
auth = _FakeAuthSession(relogin_ok=True)
site = _build_site(tmp_path, _FakeContext([page]), auth)
window = await site.list_recent_orders(since=datetime(2026, 8, 1, tzinfo=timezone.utc))
assert window.entries == []
assert window.window_fully_covered is False
assert auth.relogin_calls == 0
async def test_read_retry_does_not_swallow_navigation_failure(tmp_path):
"""导航失败仍是 OrderOperationError,不会被重登重试路径吃掉"""
class _FailingPage(_FakeOrderPage):
async def goto(self, url: str, **kwargs: Any) -> None:
raise RuntimeError("net::ERR_TIMED_OUT")
page = _FailingPage([(_ORDER_LIST_LANDED_URL, "")])
auth = _FakeAuthSession(relogin_ok=True)
site = _build_site(tmp_path, _FakeContext([page]), auth)
with pytest.raises(OrderOperationError):
await site.list_recent_orders(since=datetime(2026, 8, 1, tzinfo=timezone.utc))
assert auth.relogin_calls == 0
# ---- _parse_order_list:verify_on_site 恢复核对用,2026-08-13 用真实账号跑
# order.my.rakuten.co.jp/purchase-history/order-list 验证过这套 __INITIAL_STATE__
# 结构(pageType="ph-list" 时 orderListData 直接是结构化 JSON,不需要正则抠 DOM)。