fix(douyu): 取邮箱验证码取错邮件,导致登录永远报「邮箱验证码错误」
线上现象:同一个邮箱每次登录都取到同一个旧验证码(UID=1、几小时前那封), 提交必然失败;整体重试 20 次又把斗鱼的发信配额打爆。 两个根因: 1. Roundcube 返回的邮件列表本来就是 UID 降序(最新在前),代码又 reversed() 了一次,于是从收件箱最旧的一封开始读; 2. _parse_rc_date 用 UTC 作为基准,而 Roundcube 显示的是邮箱本地时间,旧邮件 被凭空加了 8 小时,看起来比发送时间还新,时间过滤形同虚设。 改动: - 发信前用 latest_douyu_uid() 记录收件箱最新斗鱼邮件 UID,之后只接受 UID 更大的邮件,精确锁定「本次发送触发的那封」,不再依赖列表顺序和日期解析; - 明确按 UID 降序读取,去掉多余的 reversed(); - 日期解析改为本地时区 aware datetime,时间兜底过滤才真正生效; - 斗鱼返回「邮箱验证码发送次数已达上限」时抛 EmailQuotaError,直接终止批次 不再重试,避免继续烧配额。 线上实测(忧伤的心750 / aakpfc@bdhg.xyz):修复前每次读 UID=1 的旧码 942715;修复后基线 UID=20、按发送时间与快照过滤均正确跳过旧邮件,正文读取 取到最新一封。
This commit is contained in:
@@ -261,7 +261,11 @@ class EmailVerifier:
|
||||
@staticmethod
|
||||
def _parse_rc_date(date_str: str) -> datetime | None:
|
||||
"""
|
||||
解析 Roundcube 返回的日期字符串为 datetime。
|
||||
解析 Roundcube 返回的日期字符串为「本地时区」的 aware datetime。
|
||||
|
||||
Roundcube 显示的是邮箱服务器本地时间(如"今天 09:02"),因此基准必须
|
||||
取本地时间;早期实现用 UTC 作为 now,会把 09:02 当成 UTC 09:02,
|
||||
等于凭空多出 8 小时,导致旧邮件被误判成"比发送时间还新"而无法跳过。
|
||||
|
||||
格式示例:
|
||||
- "今天 09:02" → 今天的 09:02
|
||||
@@ -270,7 +274,9 @@ class EmailVerifier:
|
||||
- "2026-06-20" → 直接解析
|
||||
- "06-20" → 今年的该日期
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
# now(tz=UTC).astimezone() 得到带本地时区信息的当前时间,时间戳换算才准确。
|
||||
now = datetime.now(tz=UTC).astimezone()
|
||||
local_tz = now.tzinfo
|
||||
|
||||
# 今天
|
||||
if date_str.startswith("今天"):
|
||||
@@ -322,14 +328,16 @@ class EmailVerifier:
|
||||
|
||||
# 日期格式 2026-06-20
|
||||
try:
|
||||
return datetime.strptime(date_str.strip(), "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
return datetime.strptime(date_str.strip(), "%Y-%m-%d").replace(
|
||||
tzinfo=local_tz
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 日期格式 2026-06-20 14:30
|
||||
try:
|
||||
return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M").replace(
|
||||
tzinfo=UTC
|
||||
tzinfo=local_tz
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -337,21 +345,23 @@ class EmailVerifier:
|
||||
# 日期格式 2026-06-20 14:30:00
|
||||
try:
|
||||
return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M:%S").replace(
|
||||
tzinfo=UTC
|
||||
tzinfo=local_tz
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 日期格式 06-20
|
||||
try:
|
||||
dt = datetime.strptime(date_str.strip(), "%m-%d").replace(tzinfo=UTC)
|
||||
dt = datetime.strptime(date_str.strip(), "%m-%d").replace(tzinfo=local_tz)
|
||||
return dt.replace(year=now.year)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 日期格式 06-20 14:30
|
||||
try:
|
||||
dt = datetime.strptime(date_str.strip(), "%m-%d %H:%M").replace(tzinfo=UTC)
|
||||
dt = datetime.strptime(date_str.strip(), "%m-%d %H:%M").replace(
|
||||
tzinfo=local_tz
|
||||
)
|
||||
return dt.replace(year=now.year)
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -365,8 +375,9 @@ class EmailVerifier:
|
||||
max_wait: int = 60,
|
||||
interval: int = 3,
|
||||
after_timestamp: float | None = None,
|
||||
allow_old_seconds: int = 15,
|
||||
allow_old_seconds: int = 0,
|
||||
stop_event: threading.Event | None = None,
|
||||
min_uid: int = 0,
|
||||
) -> str:
|
||||
"""
|
||||
轮询获取斗鱼验证码。
|
||||
@@ -374,9 +385,10 @@ class EmailVerifier:
|
||||
Args:
|
||||
max_wait: 最大等待时间(秒)
|
||||
interval: 轮询间隔(秒)
|
||||
after_timestamp: 发送验证码请求的时间戳,用于过滤旧邮件
|
||||
after_timestamp: 发送验证码请求的时间戳,min_uid 缺失时用于过滤旧邮件
|
||||
allow_old_seconds: 允许的时间偏移(秒)
|
||||
stop_event: 外部停止信号,触发后尽快中断等待
|
||||
min_uid: 发信前收件箱最新斗鱼邮件的 UID,只接受比它更新的邮件
|
||||
|
||||
Returns:
|
||||
6位验证码
|
||||
@@ -392,7 +404,7 @@ class EmailVerifier:
|
||||
|
||||
try:
|
||||
code = self._fetch_code_via_roundcube(
|
||||
after_timestamp, allow_old_seconds
|
||||
after_timestamp, allow_old_seconds, min_uid
|
||||
)
|
||||
if code:
|
||||
logger.success(f"获取到验证码: {code}")
|
||||
@@ -416,24 +428,57 @@ class EmailVerifier:
|
||||
|
||||
# ── Roundcube 方式获取验证码 ────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _is_douyu_code_mail(msg: dict) -> bool:
|
||||
"""判断是否斗鱼验证码邮件。"""
|
||||
subject = msg.get("subject", "")
|
||||
return "斗鱼" in subject or "验证码" in subject
|
||||
|
||||
def latest_douyu_uid(self) -> int:
|
||||
"""返回当前收件箱里最新一封斗鱼验证码邮件的 UID,没有则 0。
|
||||
|
||||
发信前先取一次快照,之后只接受 UID 更大的邮件,就能精确锁定
|
||||
「本次发送触发的那封」,不依赖邮件列表顺序或日期解析。
|
||||
"""
|
||||
try:
|
||||
messages = self._rc_fetch_mail_list()
|
||||
except Exception as e: # noqa: BLE001 外部接口边界需要保留宽泛异常兜底
|
||||
logger.warning(f"Roundcube: 获取基线 UID 失败: {e}")
|
||||
return 0
|
||||
uids = [msg["uid"] for msg in messages if self._is_douyu_code_mail(msg)]
|
||||
return max(uids) if uids else 0
|
||||
|
||||
def _fetch_code_via_roundcube(
|
||||
self,
|
||||
after_timestamp: float | None = None,
|
||||
allow_old_seconds: int = 15,
|
||||
allow_old_seconds: int = 0,
|
||||
min_uid: int = 0,
|
||||
) -> str | None:
|
||||
"""通过 Roundcube API 获取最新斗鱼验证码"""
|
||||
"""通过 Roundcube API 获取最新斗鱼验证码。
|
||||
|
||||
``min_uid`` 为发信前的快照 UID,只有比它更新的邮件才会被采纳;
|
||||
缺失时才退回按发送时间过滤(Roundcube 日期只有分钟精度,容易被
|
||||
上一轮的邮件蒙混过关)。
|
||||
"""
|
||||
messages = self._rc_fetch_mail_list()
|
||||
|
||||
# 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看)
|
||||
douyu_msgs = [
|
||||
msg
|
||||
for msg in messages
|
||||
if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "")
|
||||
]
|
||||
# 明确按 UID 降序:Roundcube 返回的顺序不保证,之前用 reversed()
|
||||
# 会把最新的邮件排到最后,结果每次都读到收件箱最旧的那封旧验证码。
|
||||
douyu_msgs = sorted(
|
||||
(msg for msg in messages if self._is_douyu_code_mail(msg)),
|
||||
key=lambda msg: msg["uid"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
for msg in reversed(douyu_msgs): # UID 最大的先看
|
||||
# 检查时间
|
||||
if after_timestamp and msg.get("date"):
|
||||
for msg in douyu_msgs:
|
||||
if min_uid and msg["uid"] <= min_uid:
|
||||
logger.debug(
|
||||
f"Roundcube: 邮件 UID={msg['uid']} 不晚于发信快照 {min_uid},跳过"
|
||||
)
|
||||
continue
|
||||
|
||||
# 没有快照时才用时间兜底
|
||||
if not min_uid and after_timestamp and msg.get("date"):
|
||||
msg_dt = self._parse_rc_date(msg["date"])
|
||||
if msg_dt is None:
|
||||
# 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过
|
||||
|
||||
+36
-10
@@ -46,6 +46,10 @@ class CredentialError(ValueError):
|
||||
self.status_message = status_message or message
|
||||
|
||||
|
||||
class EmailQuotaError(RuntimeError):
|
||||
"""斗鱼邮箱验证码发送次数已达上限,当天继续重试只会浪费配额。"""
|
||||
|
||||
|
||||
class AccountLike(Protocol):
|
||||
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
|
||||
|
||||
@@ -319,12 +323,22 @@ class DouyuLogin:
|
||||
|
||||
# 4️⃣ 发送邮箱验证
|
||||
logger.info("步骤4: 发送邮箱验证...")
|
||||
# 发信前先记录收件箱里最新的斗鱼邮件 UID:之后只认比它更新的邮件,
|
||||
# 才能确保取到的是「本次发送触发的那封」,而不是上一轮残留的旧验证码。
|
||||
verifier = self._create_email_verifier()
|
||||
baseline_uid = verifier.latest_douyu_uid()
|
||||
logger.info(f"步骤4: 发信前最新斗鱼邮件 UID={baseline_uid}")
|
||||
email_sent_at = time.time()
|
||||
self._send_email_verify(value)
|
||||
|
||||
# 5️⃣ IMAP获取验证码
|
||||
logger.info("步骤5: 获取邮箱验证码...")
|
||||
verify_code = self._get_email_code(after_timestamp=email_sent_at)
|
||||
verify_code = verifier.get_verification_code(
|
||||
max_wait=60,
|
||||
after_timestamp=email_sent_at,
|
||||
min_uid=baseline_uid,
|
||||
stop_event=self.stop_event,
|
||||
)
|
||||
|
||||
# 6️⃣ 提交验证码
|
||||
logger.info("步骤6: 提交验证码...")
|
||||
@@ -398,6 +412,11 @@ class DouyuLogin:
|
||||
return LoginResult(
|
||||
success=False, message=str(e), code="email_login_failed"
|
||||
)
|
||||
except EmailQuotaError as e:
|
||||
logger.error(f"登录失败(邮箱验证码发送次数已达上限,不再重试): {e}")
|
||||
return LoginResult(
|
||||
success=False, message=str(e), code="email_quota_exceeded"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_login_retries > 0:
|
||||
@@ -823,13 +842,26 @@ class DouyuLogin:
|
||||
)
|
||||
|
||||
if payload.get("error") != 0:
|
||||
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
|
||||
message = str(payload.get("msg") or "未知错误")
|
||||
if "上限" in message:
|
||||
# 当天配额已用完,继续重试也不会再发信,直接终止整个批次。
|
||||
raise EmailQuotaError(f"发送验证邮件失败: {message}")
|
||||
raise ValueError(f"发送验证邮件失败: {message}")
|
||||
|
||||
logger.info("验证邮件已发送")
|
||||
|
||||
def _get_email_code(self, after_timestamp: float | None = None) -> str:
|
||||
"""获取邮箱验证码"""
|
||||
verifier = EmailVerifier(
|
||||
"""获取邮箱验证码(兼容入口,内部自建验证器)。"""
|
||||
verifier = self._create_email_verifier()
|
||||
return verifier.get_verification_code(
|
||||
max_wait=60,
|
||||
after_timestamp=after_timestamp,
|
||||
stop_event=self.stop_event,
|
||||
)
|
||||
|
||||
def _create_email_verifier(self) -> EmailVerifier:
|
||||
"""创建邮箱验证器。"""
|
||||
return EmailVerifier(
|
||||
imap_server=self.account.email_imap_server,
|
||||
imap_port=self.account.email_imap_port,
|
||||
username=self.account.email,
|
||||
@@ -837,12 +869,6 @@ class DouyuLogin:
|
||||
use_ssl=self.account.email_imap_ssl,
|
||||
)
|
||||
|
||||
return verifier.get_verification_code(
|
||||
max_wait=60,
|
||||
after_timestamp=after_timestamp,
|
||||
stop_event=self.stop_event,
|
||||
)
|
||||
|
||||
def _submit_verify_code(self, remote_code: str, verify_code: str) -> str:
|
||||
"""
|
||||
提交验证码
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""回归:取验证码必须取「本次发送触发的那封」新邮件。
|
||||
|
||||
线上现象:同一个邮箱每次登录都取到同一个旧验证码(UID=1,几小时前那封),
|
||||
提交必然报「邮箱验证码错误」。两个原因:
|
||||
|
||||
1. Roundcube 返回的邮件列表本来就是「UID 降序(最新在前)」,代码又
|
||||
``reversed()`` 了一次,于是变成从最旧的一封开始读;
|
||||
2. ``_parse_rc_date`` 用 UTC 作为基准,而 Roundcube 显示的是邮箱本地时间,
|
||||
旧邮件被凭空加了 8 小时,看起来比发送时间还新,时间过滤形同虚设。
|
||||
|
||||
现在改为:发信前记录收件箱里最新的斗鱼邮件 UID,之后只接受 UID 更大的邮件;
|
||||
日期解析按本地时间,仅在拿不到快照时才用时间兜底。
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.douyu.email_verifier import EmailVerifier
|
||||
|
||||
SUBJECT = "验证码 - 斗鱼"
|
||||
|
||||
|
||||
class _FakeVerifier(EmailVerifier):
|
||||
"""把 Roundcube 的网络访问换成内存数据。"""
|
||||
|
||||
def __init__(self, messages: list[dict], codes: dict[int, str]):
|
||||
super().__init__(username="user@example.com", password="password")
|
||||
self._messages = messages
|
||||
self._codes = codes
|
||||
self.read_uids: list[int] = []
|
||||
|
||||
def _rc_fetch_mail_list(self) -> list[dict]:
|
||||
return list(self._messages)
|
||||
|
||||
def _rc_fetch_email_body(self, uid: int) -> str | None:
|
||||
self.read_uids.append(uid)
|
||||
code = self._codes.get(uid)
|
||||
return f"<div>您的验证码是 {code}</div>" if code else None
|
||||
|
||||
|
||||
def _msg(uid: int, date: str) -> dict:
|
||||
return {"uid": uid, "subject": SUBJECT, "date": date}
|
||||
|
||||
|
||||
def _newest_first(uids: list[int]) -> list[dict]:
|
||||
"""Roundcube 实际返回的顺序:UID 从大到小。"""
|
||||
return [_msg(uid, "今天 12:00") for uid in sorted(uids, reverse=True)]
|
||||
|
||||
|
||||
class TestEmailCodePick:
|
||||
def test_reads_newest_mail_when_list_is_newest_first(self):
|
||||
verifier = _FakeVerifier(
|
||||
_newest_first([1, 2, 3]),
|
||||
{1: "111111", 2: "222222", 3: "333333"},
|
||||
)
|
||||
|
||||
assert verifier._fetch_code_via_roundcube() == "333333"
|
||||
assert verifier.read_uids == [3]
|
||||
|
||||
def test_reads_newest_mail_when_list_is_oldest_first(self):
|
||||
verifier = _FakeVerifier(
|
||||
[_msg(uid, "今天 12:00") for uid in (1, 2, 3)],
|
||||
{1: "111111", 2: "222222", 3: "333333"},
|
||||
)
|
||||
|
||||
assert verifier._fetch_code_via_roundcube() == "333333"
|
||||
|
||||
def test_min_uid_waits_for_a_newer_mail(self):
|
||||
verifier = _FakeVerifier(
|
||||
_newest_first([1, 2, 3]),
|
||||
{1: "111111", 2: "222222", 3: "333333"},
|
||||
)
|
||||
|
||||
# 发信前最新是 UID=3,还没收到新邮件时不应返回任何旧验证码
|
||||
assert verifier._fetch_code_via_roundcube(min_uid=3) is None
|
||||
|
||||
verifier._messages = _newest_first([1, 2, 3, 4])
|
||||
verifier._codes[4] = "444444"
|
||||
assert verifier._fetch_code_via_roundcube(min_uid=3) == "444444"
|
||||
assert verifier.read_uids == [4]
|
||||
|
||||
def test_send_time_filter_skips_older_mail(self):
|
||||
"""没有 UID 快照时,比发送时间早的邮件必须被跳过。"""
|
||||
now = datetime.now(tz=UTC).astimezone()
|
||||
verifier = _FakeVerifier(
|
||||
[_msg(1, f"今天 {now.hour:02d}:{max(0, now.minute - 30):02d}")],
|
||||
{1: "111111"},
|
||||
)
|
||||
|
||||
assert (
|
||||
verifier._fetch_code_via_roundcube(after_timestamp=now.timestamp()) is None
|
||||
)
|
||||
assert verifier.read_uids == []
|
||||
|
||||
def test_parse_rc_date_uses_local_time(self):
|
||||
now = datetime.now(tz=UTC).astimezone()
|
||||
parsed = EmailVerifier._parse_rc_date("今天 18:37")
|
||||
|
||||
assert parsed is not None
|
||||
assert (parsed.hour, parsed.minute) == (18, 37)
|
||||
# 同一天,且不是 UTC 解释(否则在 UTC+8 会差 8 小时)
|
||||
assert parsed.date() == now.replace(hour=18, minute=37).date()
|
||||
|
||||
def test_latest_douyu_uid_ignores_other_mail(self):
|
||||
verifier = _FakeVerifier(
|
||||
[
|
||||
{"uid": 9, "subject": "广告邮件", "date": "今天 12:00"},
|
||||
_msg(4, "今天 11:00"),
|
||||
_msg(2, "今天 10:00"),
|
||||
],
|
||||
{},
|
||||
)
|
||||
|
||||
assert verifier.latest_douyu_uid() == 4
|
||||
Reference in New Issue
Block a user