From 8ad2ec91f19e98424bd3e8a92ddfa070b449f0af Mon Sep 17 00:00:00 2001 From: liuwei Date: Thu, 7 May 2026 15:14:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D864=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E9=80=80=E5=87=BA=E5=B9=B6=E9=98=BB=E6=AD=A2?= =?UTF-8?q?=E7=A9=BA=E8=BA=AB=E4=BB=BD=E8=BF=9B=E5=85=A5=E5=90=8E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- robot.py | 7 + wechat_ipad/providers/server_864/runtime.py | 479 +++++++++++--------- 2 files changed, 261 insertions(+), 225 deletions(-) diff --git a/robot.py b/robot.py index e9d93fd..ea8bd1f 100644 --- a/robot.py +++ b/robot.py @@ -652,6 +652,13 @@ class Robot: 2. provider 不应该知道本项目有哪些数据库表、后台缓存或插件系统; 3. 因此登录“流程”放到 provider,登录后的“业务初始化”继续留在 Robot。 """ + # 这里再做一次项目侧兜底校验: + # 1. provider 已经会尽量保证只有“拿到可用身份”才会调进来; + # 2. 但 Robot 这一层承接的是联系人同步、插件注入、消息归档等重业务动作,不能接受空账号继续执行; + # 3. 因此只要 `wxid/nickname` 都为空,就立刻阻断后台初始化,强制回到扫码登录流程。 + if not str(login_identity.get("wxid", "") or "").strip() and not str(login_identity.get("nickname", "") or "").strip(): + raise RuntimeError("当前未拿到可用登录账号身份,已阻止进入后台初始化流程") + self.wxid = login_identity.get("wxid", "") self.nickname = login_identity.get("nickname", "") self.alias = login_identity.get("alias", "") diff --git a/wechat_ipad/providers/server_864/runtime.py b/wechat_ipad/providers/server_864/runtime.py index af9ef53..077feac 100644 --- a/wechat_ipad/providers/server_864/runtime.py +++ b/wechat_ipad/providers/server_864/runtime.py @@ -101,7 +101,14 @@ class Server864RuntimeMixin: login_way=login_way, ) - await on_login_ready(self.get_login_identity()) + login_identity = self.get_login_identity() + # 在 provider 侧再做一次硬校验: + # 1. `_ensure_login()` 已经负责等待真正可用的登录态,但运行期代码后续可能继续演进; + # 2. 这里补一道收口保护,可以避免未来某次重构把“空身份”再次漏进 Robot 业务层; + # 3. 一旦这里失败,说明当前 provider 还没有拿到稳定账号身份,必须继续停留在登录阶段。 + if not self._has_login_identity(login_identity): + raise RuntimeError("server_864 登录流程未拿到可用账号身份,已阻止进入后台业务初始化") + await on_login_ready(login_identity) logger.info("server_864 登录成功") await self._set_runtime_running(True, on_runtime_state_change=on_runtime_state_change, logger=logger) @@ -175,50 +182,243 @@ class Server864RuntimeMixin: ) return - # 先探测一次 864 当前阶段,让 Dashboard 能直接区分“等服务端准备”和“需要扫码”: - # 1. 864 的未登录态并不只有一种,部分场景其实是远端连接对象还没建好; - # 2. 若首页始终只显示“未登录”,运维很难判断下一步是等服务端还是去扫码; - # 3. 这里把差异压缩成轻量阶段字段,供前端直接展示,不改动核心登录流程。 - login_stage_snapshot = await self._probe_login_stage() - await self._safe_callback( - on_login_qr_update, - login_stage_snapshot, - logger=logger, - callback_name="on_login_qr_update", - ) - - uuid, url = await self.get_qr_code(print_qr=True, login_qr_api=login_qr_api, login_way=login_way) - scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" - await self._safe_callback( - on_login_qr_update, - { - "uuid": uuid, - "url": url, - "scan_url": scan_url, - "expires_in": None, - "status": "waiting", - "status_text": "等待扫码登录", - "login_source": "fresh_qr", - "provider_name": "server_864", - "provider_stage": "waiting_scan", - "connection_ready": False, - "login_required": True, - "login_qr_api": login_qr_api, - "login_way": login_way, - }, - logger=logger, - callback_name="on_login_qr_update", - ) - while True: + # 这里把“申请二维码 -> 轮询扫码 -> 登录收口”包成一轮完整会话: + # 1. 864 某些失败不是用户操作问题,而是服务端在扫码后主动中断当前连接; + # 2. 对这类场景,最合理的行为不是让线程退出,而是把错误留在前端并回到下一轮扫码; + # 3. 这样可以满足“没登录成功就不要进入后台,但页面持续引导重新登录”的产品预期。 + login_stage_snapshot = await self._probe_login_stage() + await self._safe_callback( + on_login_qr_update, + login_stage_snapshot, + logger=logger, + callback_name="on_login_qr_update", + ) + + uuid, url = await self.get_qr_code(print_qr=True, login_qr_api=login_qr_api, login_way=login_way) + scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" + effective_time = 0 + raw_state = 0 + await self._safe_callback( + on_login_qr_update, + { + "uuid": uuid, + "url": url, + "scan_url": scan_url, + "expires_in": None, + "status": "waiting", + "status_text": "等待扫码登录", + "login_source": "fresh_qr", + "provider_name": "server_864", + "provider_stage": "waiting_scan", + "connection_ready": False, + "login_required": True, + "login_qr_api": login_qr_api, + "login_way": login_way, + }, + logger=logger, + callback_name="on_login_qr_update", + ) + + login_completed = False + while True: + try: + is_logged_in, login_status = await self.check_login_status() + except Exception as e: + error_message = self._normalize_login_runtime_message(str(e)) + # 轮询阶段失败时只更新前端,不中断 provider 主线程: + # 1. 线上最常见的是本轮登录连接已被服务端丢弃,继续崩线程只会让运维反复重启; + # 2. 这里先把明确错误展示给前端,再回到外层重新申请二维码; + # 3. 这样用户在新环境里可以直接继续扫码,不需要手工重启 ABOT。 + await self._safe_callback( + on_login_qr_update, + { + "uuid": uuid, + "url": url, + "scan_url": scan_url, + "status": "unavailable", + "status_text": error_message, + "login_source": "fresh_qr", + "provider_name": "server_864", + "provider_stage": "status_unavailable", + "connection_ready": False, + "login_required": True, + "login_qr_api": login_qr_api, + "login_way": login_way, + }, + logger=logger, + callback_name="on_login_qr_update", + ) + logger.warning(f"server_864 登录状态轮询失败,本轮二维码作废并等待重新登录: {error_message}") + await asyncio.sleep(2) + break + + if is_logged_in: + await self._safe_callback( + on_login_qr_update, + { + "uuid": uuid, + "url": url, + "scan_url": scan_url, + "expires_in": effective_time if effective_time > 0 else None, + "status": "confirmed", + # 864 进入 `state=2` 后,已经拿到了 wxid / wxnewpass 等关键登录信息: + # 1. 这说明扫码本身已经成功,但并不代表 ABOT 后续初始化已经全部完成; + # 2. 若这里直接关闭弹窗,后面的版本过低/初始化失败就无法同步给前端; + # 3. 因此先保持弹窗可见,把阶段切到“登录收口中”,等真正初始化完成后再清理。 + "status_text": "扫码成功,等待服务端完成登录", + "login_source": "fresh_qr", + "provider_name": "server_864", + "provider_stage": "login_finalizing", + "connection_ready": True, + "login_required": False, + "raw_state": max(raw_state, 2), + "login_qr_api": login_qr_api, + "login_way": login_way, + }, + logger=logger, + callback_name="on_login_qr_update", + ) + login_completed = True + break + + # 864 的登录状态查询会回传当前 uuid 和有效期: + # 1. 真实联调中已确认 `CheckLoginStatus` 会返回 `uuid/effective_time`; + # 2. 这些值应优先作为 Dashboard 的二维码倒计时与当前扫码目标来源; + # 3. 一旦 server 侧切换了新的 uuid,这里也要及时覆盖本地展示态,避免前端一直盯着旧码。 + latest_uuid = str(login_status.get("uuid", "") or uuid).strip() or uuid + effective_time = int(login_status.get("effective_time", 0) or 0) + raw_state = int(login_status.get("raw_state", login_status.get("state", 0)) or 0) + verification_url = str(login_status.get("verification_url", "") or "").strip() + nick_name = str(login_status.get("nick_name", "") or "").strip() + head_img_url = str(login_status.get("head_img_url", "") or "").strip() + if latest_uuid != uuid: + uuid = latest_uuid + scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" + url = f"https://api.2dcode.biz/v1/create-qr-code?data={scan_url}" if scan_url else url + + # 864 的 `CheckLoginStatus` 存在一类“状态缓存已终态,但服务端连接没有继续推进”的情况: + # 1. 实测里扫码并完成安全验证后,接口可能停在 `state=4 + VerificationUrl`,前端会一直看见旧状态; + # 2. 结合服务端源码,这类 `state=4` 更接近终态/失效态,而不是可继续等待的中间态; + # 3. 因此这里把它单独识别出来,避免 Dashboard 长时间卡在“等待安全验证”的旧文案。 + raw_status_text = str(login_status.get("msg") or login_status.get("loginState") or "").strip() + if raw_state == 0: + # 864 有时会把上一轮登录留下的 VerificationUrl 一起带回来: + # 1. 但当 `raw_state=0` 时,真实含义仍然是“当前二维码等待扫码”; + # 2. 若继续优先展示旧验证链接,前端就会出现“明明没扫却提示去安全验证”的误导; + # 3. 因此这里明确以原始状态为准,并主动忽略这类陈旧 VerificationUrl。 + provider_stage = "waiting_scan" if uuid else "login_required" + status = "waiting" + status_text = raw_status_text or "等待扫码登录" + verification_url = "" + nick_name = "" + head_img_url = "" + elif raw_state == 4: + provider_stage = "login_required" + status = "expired" + if verification_url: + status_text = "安全验证链路已结束,但服务端未完成登录收口,正在准备刷新二维码" + else: + status_text = raw_status_text or "二维码状态已结束,正在准备刷新二维码" + elif raw_state == 1: + # 864 在 `state=1` 时通常已经识别出扫码账号,但还没推进到最终登录完成: + # 1. 这时接口经常会同时回传昵称、头像以及 VerificationUrl; + # 2. 但从用户体验上看,它更接近“已扫码,等待服务端确认”,不应该继续只提示去点链接; + # 3. 因此这里单独映射成可读阶段,方便 Dashboard 展示更准确的过程状态。 + provider_stage = "scan_confirmed" + status = "waiting" + display_name = nick_name or "当前微信账号" + status_text = raw_status_text or f"已扫码:{display_name},等待服务端确认登录" + elif verification_url: + provider_stage = "verification_required" + status = "waiting" + if not raw_status_text: + status_text = "扫码已完成,请继续打开验证链接完成安全验证" + else: + status_text = raw_status_text + else: + provider_stage = "waiting_scan" if uuid else "login_required" + status = "waiting" + status_text = raw_status_text or "等待扫码登录" + + if verification_url and raw_state not in {0, 4} and not raw_status_text: + status_text = "扫码已完成,请继续打开验证链接完成安全验证" + + await self._safe_callback( + on_login_qr_update, + { + "uuid": uuid, + "url": url, + "scan_url": scan_url, + "expires_in": effective_time if effective_time > 0 else None, + "status": status, + "status_text": status_text, + "login_source": "fresh_qr", + "provider_name": "server_864", + "provider_stage": provider_stage, + "connection_ready": False, + "login_required": True, + "verification_url": verification_url, + "raw_state": raw_state, + "nick_name": nick_name, + "head_img_url": head_img_url, + "login_qr_api": login_qr_api, + "login_way": login_way, + }, + logger=logger, + callback_name="on_login_qr_update", + ) + + # 若 server 已明确告知二维码失效或停在终态缓存,则立即结束本轮、回到外层申请新二维码: + # 1. 这能避免 Dashboard 一直展示一张已经不可扫的旧二维码; + # 2. 864 某些版本即使 `effective_time` 还大于 0,也可能已经停在 `state=4` 终态缓存里; + # 3. 因此这里补充 `raw_state == 4` 的刷新条件,让前端能尽快得到新的登录入口。 + if effective_time <= 0 or raw_state == 4: + await self._safe_callback( + on_login_qr_update, + { + "uuid": uuid, + "url": url, + "scan_url": scan_url, + "expires_in": None, + "status": "waiting", + "status_text": "二维码已失效,正在准备新二维码", + "login_source": "refresh_qr", + "provider_name": "server_864", + "provider_stage": "waiting_scan", + "connection_ready": False, + "login_required": True, + "login_qr_api": login_qr_api, + "login_way": login_way, + }, + logger=logger, + callback_name="on_login_qr_update", + ) + break + await asyncio.sleep(5) + + if not login_completed: + continue + try: - is_logged_in, login_status = await self.check_login_status() + await self._wait_init_ready(logger=logger) + identity_ready = await self._refresh_identity_from_profile(logger=logger) + if not identity_ready: + raise RuntimeError("扫码完成后未获取到可用账号身份,请重新扫码登录") + ipad_config["wxid"] = self.wxid + ipad_config["login_time"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + self._save_runtime_state( + state_path=state_path, + state_payload={"wxid": self.wxid, "login_time": ipad_config["login_time"]}, + logger=logger, + ) + return except Exception as e: - error_message = self._normalize_login_runtime_message(str(e)) - # 登录轮询阶段一旦出现明确错误,应立刻同步到 Dashboard: - # 1. 之前这类异常只会停在控制台日志,前端继续显示旧的“等待扫码/等待验证”文案; - # 2. 用户已经扫码后,最需要的是第一时间知道“为什么卡住了”; - # 3. 因此这里把错误直接回写成登录态,让弹窗成为当前环境登录的真实看板。 + error_message = self._normalize_post_scan_failure_message(str(e)) + # 扫码成功后的收口阶段同样需要把异常同步给前端: + # 1. 这类错误往往发生在“state=2 之后但正式进入业务前”,最容易被误判成前端没刷新; + # 2. 例如当前用户遇到的“客户端版本过低”就是在这个阶段由服务端主动断开; + # 3. 这里不再直接抛异常退出,而是把错误留在弹窗里并继续等待下一轮扫码。 await self._safe_callback( on_login_qr_update, { @@ -232,196 +432,25 @@ class Server864RuntimeMixin: "provider_stage": "status_unavailable", "connection_ready": False, "login_required": True, + "raw_state": max(raw_state, 2), "login_qr_api": login_qr_api, "login_way": login_way, }, logger=logger, callback_name="on_login_qr_update", ) - raise RuntimeError(error_message) from e - if is_logged_in: - await self._safe_callback( - on_login_qr_update, - { - "uuid": uuid, - "url": url, - "scan_url": scan_url, - "expires_in": effective_time if effective_time > 0 else None, - "status": "confirmed", - # 864 进入 `state=2` 后,已经拿到了 wxid / wxnewpass 等关键登录信息: - # 1. 这说明扫码本身已经成功,但并不代表 ABOT 后续初始化已经全部完成; - # 2. 若这里直接关闭弹窗,后面的版本过低/初始化失败就无法同步给前端; - # 3. 因此先保持弹窗可见,把阶段切到“登录收口中”,等真正初始化完成后再清理。 - "status_text": "扫码成功,等待服务端完成登录", - "login_source": "fresh_qr", - "provider_name": "server_864", - "provider_stage": "login_finalizing", - "connection_ready": True, - "login_required": False, - "raw_state": 2, - "login_qr_api": login_qr_api, - "login_way": login_way, - }, - logger=logger, - callback_name="on_login_qr_update", - ) - break + logger.warning(f"server_864 登录收口失败,继续停留在扫码引导态等待重新登录: {error_message}") + await asyncio.sleep(2) - # 864 的登录状态查询会回传当前 uuid 和有效期: - # 1. 真实联调中已确认 `CheckLoginStatus` 会返回 `uuid/effective_time`; - # 2. 这些值应优先作为 Dashboard 的二维码倒计时与当前扫码目标来源; - # 3. 一旦 server 侧切换了新的 uuid,这里也要及时覆盖本地展示态,避免前端一直盯着旧码。 - latest_uuid = str(login_status.get("uuid", "") or uuid).strip() or uuid - effective_time = int(login_status.get("effective_time", 0) or 0) - raw_state = int(login_status.get("raw_state", login_status.get("state", 0)) or 0) - verification_url = str(login_status.get("verification_url", "") or "").strip() - nick_name = str(login_status.get("nick_name", "") or "").strip() - head_img_url = str(login_status.get("head_img_url", "") or "").strip() - if latest_uuid != uuid: - uuid = latest_uuid - scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" - url = f"https://api.2dcode.biz/v1/create-qr-code?data={scan_url}" if scan_url else url - - # 864 的 `CheckLoginStatus` 存在一类“状态缓存已终态,但服务端连接没有继续推进”的情况: - # 1. 实测里扫码并完成安全验证后,接口可能停在 `state=4 + VerificationUrl`,前端会一直看见旧状态; - # 2. 结合服务端源码,这类 `state=4` 更接近终态/失效态,而不是可继续等待的中间态; - # 3. 因此这里把它单独识别出来,避免 Dashboard 长时间卡在“等待安全验证”的旧文案。 - raw_status_text = str(login_status.get("msg") or login_status.get("loginState") or "").strip() - if raw_state == 0: - # 864 有时会把上一轮登录留下的 VerificationUrl 一起带回来: - # 1. 但当 `raw_state=0` 时,真实含义仍然是“当前二维码等待扫码”; - # 2. 若继续优先展示旧验证链接,前端就会出现“明明没扫却提示去安全验证”的误导; - # 3. 因此这里明确以原始状态为准,并主动忽略这类陈旧 VerificationUrl。 - provider_stage = "waiting_scan" if uuid else "login_required" - status = "waiting" - status_text = raw_status_text or "等待扫码登录" - verification_url = "" - nick_name = "" - head_img_url = "" - elif raw_state == 4: - provider_stage = "login_required" - status = "expired" - if verification_url: - status_text = "安全验证链路已结束,但服务端未完成登录收口,正在准备刷新二维码" - else: - status_text = raw_status_text or "二维码状态已结束,正在准备刷新二维码" - elif raw_state == 1: - # 864 在 `state=1` 时通常已经识别出扫码账号,但还没推进到最终登录完成: - # 1. 这时接口经常会同时回传昵称、头像以及 VerificationUrl; - # 2. 但从用户体验上看,它更接近“已扫码,等待服务端确认”,不应该继续只提示去点链接; - # 3. 因此这里单独映射成可读阶段,方便 Dashboard 展示更准确的过程状态。 - provider_stage = "scan_confirmed" - status = "waiting" - display_name = nick_name or "当前微信账号" - status_text = raw_status_text or f"已扫码:{display_name},等待服务端确认登录" - elif verification_url: - provider_stage = "verification_required" - status = "waiting" - if not raw_status_text: - status_text = "扫码已完成,请继续打开验证链接完成安全验证" - else: - status_text = raw_status_text - else: - provider_stage = "waiting_scan" if uuid else "login_required" - status = "waiting" - status_text = raw_status_text or "等待扫码登录" - - if verification_url and raw_state not in {0, 4} and not raw_status_text: - status_text = "扫码已完成,请继续打开验证链接完成安全验证" - - await self._safe_callback( - on_login_qr_update, - { - "uuid": uuid, - "url": url, - "scan_url": scan_url, - "expires_in": effective_time if effective_time > 0 else None, - "status": status, - "status_text": status_text, - "login_source": "fresh_qr", - "provider_name": "server_864", - "provider_stage": provider_stage, - "connection_ready": False, - "login_required": True, - "verification_url": verification_url, - "raw_state": raw_state, - "nick_name": nick_name, - "head_img_url": head_img_url, - "login_qr_api": login_qr_api, - "login_way": login_way, - }, - logger=logger, - callback_name="on_login_qr_update", - ) - - # 若 server 已明确告知二维码失效或停在终态缓存,则立即重新申请一张新码: - # 1. 这能避免 Dashboard 一直展示一张已经不可扫的旧二维码; - # 2. 864 某些版本即使 `effective_time` 还大于 0,也可能已经停在 `state=4` 终态缓存里; - # 3. 因此这里补充 `raw_state == 4` 的刷新条件,让前端能尽快得到新的登录入口。 - if effective_time <= 0 or raw_state == 4: - uuid, url = await self.get_qr_code(print_qr=True, login_qr_api=login_qr_api, login_way=login_way) - scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" - await self._safe_callback( - on_login_qr_update, - { - "uuid": uuid, - "url": url, - "scan_url": scan_url, - "expires_in": None, - "status": "waiting", - "status_text": "二维码已刷新,等待扫码登录", - "login_source": "refresh_qr", - "provider_name": "server_864", - "provider_stage": "waiting_scan", - "connection_ready": False, - "login_required": True, - "login_qr_api": login_qr_api, - "login_way": login_way, - }, - logger=logger, - callback_name="on_login_qr_update", - ) - await asyncio.sleep(5) - - try: - await self._wait_init_ready(logger=logger) - identity_ready = await self._refresh_identity_from_profile(logger=logger) - if not identity_ready: - raise RuntimeError("扫码完成后未获取到可用账号身份,请重新扫码登录") - ipad_config["wxid"] = self.wxid - ipad_config["login_time"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - self._save_runtime_state( - state_path=state_path, - state_payload={"wxid": self.wxid, "login_time": ipad_config["login_time"]}, - logger=logger, - ) - except Exception as e: - error_message = self._normalize_post_scan_failure_message(str(e)) - # 扫码成功后的收口阶段同样需要把异常同步给前端: - # 1. 这类错误往往发生在“state=2 之后但正式进入业务前”,最容易被误判成前端没刷新; - # 2. 例如当前用户遇到的“客户端版本过低”就是在这个阶段由服务端主动断开; - # 3. 因此这里明确把阶段标成 `status_unavailable`,让弹窗继续停留并展示真实原因。 - await self._safe_callback( - on_login_qr_update, - { - "uuid": uuid, - "url": url, - "scan_url": scan_url, - "status": "unavailable", - "status_text": error_message, - "login_source": "fresh_qr", - "provider_name": "server_864", - "provider_stage": "status_unavailable", - "connection_ready": False, - "login_required": True, - "raw_state": 2, - "login_qr_api": login_qr_api, - "login_way": login_way, - }, - logger=logger, - callback_name="on_login_qr_update", - ) - raise RuntimeError(error_message) from e + @staticmethod + def _has_login_identity(login_identity: dict[str, Any] | None) -> bool: + """判断当前 provider 是否已经拿到可用于进入业务层的账号身份。""" + payload = dict(login_identity or {}) + # 这里把“可用身份”的标准压到最小: + # 1. 864 有些接口可能先拿到昵称、后拿到 wxid,也可能顺序相反; + # 2. 但两者都为空时,业务层无法知道当前到底是谁登录了; + # 3. 因此至少要拿到 `wxid` 或 `nickname` 之一,才允许继续进入 Robot 初始化。 + return bool(str(payload.get("wxid", "") or "").strip() or str(payload.get("nickname", "") or "").strip()) async def _probe_login_stage(self) -> dict[str, Any]: """探测 864 当前登录阶段,供 Dashboard 展示更准确的运维状态。"""