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:
|
||||
"""
|
||||
提交验证码
|
||||
|
||||
Reference in New Issue
Block a user