diff --git a/core/douyu/email_verifier.py b/core/douyu/email_verifier.py index e112a0c..c0e1773 100644 --- a/core/douyu/email_verifier.py +++ b/core/douyu/email_verifier.py @@ -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 时跳过 diff --git a/core/douyu/login.py b/core/douyu/login.py index 3848f61..93678f3 100644 --- a/core/douyu/login.py +++ b/core/douyu/login.py @@ -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: """ 提交验证码 diff --git a/tests/test_douyu_email_code_pick.py b/tests/test_douyu_email_code_pick.py new file mode 100644 index 0000000..baa48e1 --- /dev/null +++ b/tests/test_douyu_email_code_pick.py @@ -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"
您的验证码是 {code}
" 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