diff --git a/admin/dashboard/blueprints/robot.py b/admin/dashboard/blueprints/robot.py index cf46766..652b134 100644 --- a/admin/dashboard/blueprints/robot.py +++ b/admin/dashboard/blueprints/robot.py @@ -476,6 +476,61 @@ def api_login_qr_status(): return jsonify({"success": False, "error": str(e)}), 500 +@robot_bp.route('/api/login_qr_mode', methods=['POST']) +@login_required +def api_switch_login_qr_mode(): + """切换 864 登录二维码模式,并重启登录流程。""" + try: + server = current_app.dashboard_server + robot = getattr(server, "robot", None) + if robot is None: + return jsonify({"success": False, "error": "机器人实例不可用"}), 500 + + payload = request.get_json(silent=True) or {} + login_qr_api = str(payload.get("login_qr_api", "") or "").strip() + login_way = str(payload.get("login_way", "") or "").strip() + result = robot.switch_server_864_login_entry( + login_qr_api=login_qr_api, + login_way=login_way or None, + do_logout=True, + ) + return jsonify({ + "success": True, + "message": "864 登录二维码模式已切换,新的登录线程正在启动", + "data": { + **result, + "login_qr_state": _serialize_login_qr_state(server), + }, + }) + except Exception as e: + LOG.error(f"切换 864 登录二维码模式失败: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@robot_bp.route('/api/logout_864', methods=['POST']) +@login_required +def api_logout_864(): + """退出当前 864 登录态,并重新进入二维码登录引导。""" + try: + server = current_app.dashboard_server + robot = getattr(server, "robot", None) + if robot is None: + return jsonify({"success": False, "error": "机器人实例不可用"}), 500 + + result = robot.logout_server_864_and_restart_login() + return jsonify({ + "success": True, + "message": "864 已退出当前登录态,新的二维码登录流程正在启动", + "data": { + **result, + "login_qr_state": _serialize_login_qr_state(server), + }, + }) + except Exception as e: + LOG.error(f"退出 864 登录态失败: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + # API路由 @robot_bp.route('/api/groups') @login_required diff --git a/admin/dashboard/templates/index.html b/admin/dashboard/templates/index.html index f71002d..31a353c 100644 --- a/admin/dashboard/templates/index.html +++ b/admin/dashboard/templates/index.html @@ -187,6 +187,28 @@
立即刷新状态 + + 鸿蒙二维码 + + + 标准New二维码 + + + 退出864登录 + 复制扫码链接 @@ -586,6 +608,7 @@ server_now: 0 }, loginQrCountdownSeconds: 0, + loginQrActionLoading: '', groups: [], selectedGroupForHourlyTrend: '', hourlyTrendDays: 1, @@ -622,6 +645,10 @@ loginQrCurrent() { return this.loginQrDialog.current || {}; }, + isServer864Login() { + const providerName = String(this.loginQrDialog.provider_name || '').trim().toLowerCase(); + return providerName === 'server_864' || providerName === '864'; + }, showLoginQrBanner() { return !this.loginQrDialog.logged_in; }, @@ -971,6 +998,63 @@ this.loginQrDialog.loading = false; }); }, + switch864LoginMode(loginQrApi, loginWay = '') { + if (!this.isServer864Login) { + this.$message.warning('当前不是 864 登录模式,暂不支持切换二维码入口'); + return; + } + this.loginQrActionLoading = loginQrApi; + axios.post('/robot/api/login_qr_mode', { + login_qr_api: loginQrApi, + login_way: loginWay + }) + .then(response => { + if (response.data.success) { + const state = (((response.data || {}).data || {}).login_qr_state) || {}; + if (state && Object.keys(state).length > 0) { + this.applyLoginQrState(state); + } + this.$message.success(response.data.message || '864 登录二维码入口已切换'); + setTimeout(() => this.loadLoginQrStatus(true), 1200); + return; + } + this.$message.error(response.data.error || '切换 864 登录二维码入口失败'); + }) + .catch(error => { + const errorMessage = (((error || {}).response || {}).data || {}).error || '切换 864 登录二维码入口失败'; + this.$message.error(errorMessage); + }) + .finally(() => { + this.loginQrActionLoading = ''; + }); + }, + logout864Login() { + if (!this.isServer864Login) { + this.$message.warning('当前不是 864 登录模式,暂不支持此操作'); + return; + } + this.loginQrActionLoading = 'logout_864'; + axios.post('/robot/api/logout_864') + .then(response => { + if (response.data.success) { + const state = (((response.data || {}).data || {}).login_qr_state) || {}; + if (state && Object.keys(state).length > 0) { + this.applyLoginQrState(state); + } + this.$message.success(response.data.message || '864 已退出当前登录态'); + setTimeout(() => this.loadLoginQrStatus(true), 1200); + return; + } + this.$message.error(response.data.error || '退出 864 登录态失败'); + }) + .catch(error => { + const errorMessage = (((error || {}).response || {}).data || {}).error || '退出 864 登录态失败'; + this.$message.error(errorMessage); + }) + .finally(() => { + this.loginQrActionLoading = ''; + }); + }, tickLoginQrCountdown() { if (this.loginQrDialog.logged_in) { this.loginQrCountdownSeconds = 0; diff --git a/robot.py b/robot.py index b2a761e..0b5b022 100644 --- a/robot.py +++ b/robot.py @@ -927,9 +927,106 @@ class Robot: if hasattr(self, "ipad_bot") and self.ipad_bot and hasattr(self.ipad_bot, "stop_runtime"): self.ipad_bot.stop_runtime() if self.ipad_loop: - self.ipad_loop.stop() + try: + # 事件循环运行在独立线程里,主线程这里需要走线程安全停止: + # 1. 直接 `loop.stop()` 在某些时机会留下竞态,导致旧线程迟迟不退出; + # 2. Dashboard 现在需要支持“不重启主进程,直接切换 864 登录模式”; + # 3. 因此这里改成 `call_soon_threadsafe`,让旧 provider 能更稳地收尾退出。 + self.ipad_loop.call_soon_threadsafe(self.ipad_loop.stop) + except Exception: + self.ipad_loop.stop() + if self.ipad_thread and self.ipad_thread.is_alive(): + self.ipad_thread.join(timeout=5) + self.ipad_thread = None + self.ipad_loop = None + self.ipad_bot = None self.LOG.info("wechat_ipad客户端已停止") + def switch_server_864_login_entry(self, *, login_qr_api: str, login_way: str | None = None, do_logout: bool = True) -> dict: + """切换 864 登录入口并重启登录流程。 + + 设计说明: + 1. 用户希望在后台直接切换“鸿蒙专用二维码”和“标准 New 二维码”,而不是改 `.env` 后重启整套服务; + 2. 当前 864 runtime 在独立线程里长驻运行,最稳妥的切换方式是“更新配置 -> 退出旧登录态 -> 重启 provider 登录线程”; + 3. 这样实现虽然比纯运行时热切换更直接,但代码层次更浅,也更便于后续继续接其他 server 变体。 + """ + if not isinstance(self.ipad_config, dict): + raise RuntimeError("wechat_ipad 运行时配置尚未初始化,暂时无法切换 864 登录入口") + + current_server_type = self._normalize_wechat_provider_key(self.ipad_config.get("server_type", "legacy_855")) + if current_server_type != "server_864": + raise RuntimeError("当前仅支持在 server_864 模式下切换登录二维码入口") + + normalized_login_qr_api = str(login_qr_api or "").strip().lower() + if normalized_login_qr_api not in {"harmony_api", "new", "new_x"}: + raise ValueError(f"不支持的 864 登录入口模式: {login_qr_api}") + + normalized_login_way = str(login_way or self.ipad_config.get("login_way", "mac") or "mac").strip().lower() + if normalized_login_qr_api == "harmony_api": + normalized_login_way = "harmony" + + if do_logout and self.ipad_bot and self.ipad_loop: + try: + logout_future = asyncio.run_coroutine_threadsafe(self.ipad_bot.log_out(), self.ipad_loop) + logout_future.result(timeout=20) + except Exception as e: + # 切换登录模式时,退出旧会话失败不应阻断后续重启: + # 1. 某些 864 版本在会话已失效时会直接返回错误; + # 2. 用户真正关心的是“后台能否尽快切到新的二维码入口”; + # 3. 因此这里记录日志后继续向下执行重启流程。 + self.LOG.warning(f"切换 864 登录入口前执行退出请求失败,继续重启登录流程: {e}") + + self.ipad_config["login_qr_api"] = normalized_login_qr_api + self.ipad_config["login_way"] = normalized_login_way + if isinstance(getattr(self.config, "wechat_ipad", None), dict): + self.config.wechat_ipad["login_qr_api"] = normalized_login_qr_api + self.config.wechat_ipad["login_way"] = normalized_login_way + + self._clear_ipad_identity_cache() + switch_label_map = { + "harmony_api": "864 鸿蒙专用二维码", + "new": "864 标准 New 二维码", + "new_x": f"864 NewX 二维码({normalized_login_way})", + } + with self._ipad_login_qr_lock: + self.ipad_login_qr_state = { + "logged_in": False, + "active": True, + "status": "waiting", + "provider_name": "server_864", + "provider_stage": "login_required", + "connection_ready": False, + "login_required": True, + "status_text": f"正在切换到{switch_label_map.get(normalized_login_qr_api, normalized_login_qr_api)},请稍候刷新二维码", + "current": { + "login_qr_api": normalized_login_qr_api, + "login_way": normalized_login_way, + "updated_at": time.time(), + "updated_at_text": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + }, + "history": [], + "updated_at": time.time(), + } + + self.stop_wechat_ipad() + if not self.init_wechat_ipad(): + raise RuntimeError("重启 wechat_ipad 登录线程失败,请检查 864 服务端与配置") + + return { + "server_type": "server_864", + "login_qr_api": normalized_login_qr_api, + "login_way": normalized_login_way, + "message": switch_label_map.get(normalized_login_qr_api, normalized_login_qr_api), + } + + def logout_server_864_and_restart_login(self) -> dict: + """退出当前 864 登录态,并按现有二维码入口重新进入登录引导。""" + return self.switch_server_864_login_entry( + login_qr_api=str(self.ipad_config.get("login_qr_api", "new_x") or "new_x"), + login_way=str(self.ipad_config.get("login_way", "mac") or "mac"), + do_logout=True, + ) + def keep_running_and_block_process(self) -> None: """ 保持机器人运行,不让进程退出 diff --git a/wechat_ipad/providers/server_864/login.py b/wechat_ipad/providers/server_864/login.py index a277613..17776e1 100644 --- a/wechat_ipad/providers/server_864/login.py +++ b/wechat_ipad/providers/server_864/login.py @@ -105,7 +105,19 @@ class LoginMixin(Server864APIClientBase): normalized_login_qr_api = str(login_qr_api or "new_x").strip().lower() normalized_login_way = self._normalize_login_way(login_way) - if normalized_login_qr_api in {"new_x", "x", "newx"}: + if normalized_login_qr_api in {"harmony_api", "harmony", "harmony_login_api"}: + # `HarmonyLoginApi` 是 864 服务端单独暴露的一条鸿蒙二维码链路: + # 1. 用户当前希望在 Dashboard 上显式切换“鸿蒙专用二维码”和“标准 New 二维码”; + # 2. 该接口的请求模型与 `GetLoginQrCodeNew` 一致,仍然只需要 `Proxy/Check`; + # 3. 因此这里单独加一个模式键,不去复用 `new_x + way=harmony`,避免两条链路在运维上混淆。 + data = await self._request_data( + "post", + "/login/HarmonyLoginApi", + json_body={"Proxy": proxy_value, "Check": False}, + timeout=30, + ) + uuid, qr_url = self._extract_qr_response(data) + elif normalized_login_qr_api in {"new_x", "x", "newx"}: try: # NewX 是当前 864 联调里更完整的一条登录链路: # 1. 它支持 `Way` 指定登录端形态,兼容更多 server 变体; @@ -126,6 +138,14 @@ class LoginMixin(Server864APIClientBase): timeout=30, ) uuid, qr_url = self._extract_qr_response(data) + elif normalized_login_qr_api in {"new", "legacy_new"}: + data = await self._request_data( + "post", + "/login/GetLoginQrCodeNew", + json_body={"Proxy": proxy_value, "Check": False}, + timeout=30, + ) + uuid, qr_url = self._extract_qr_response(data) else: data = await self._request_data( "post", @@ -211,5 +231,12 @@ class LoginMixin(Server864APIClientBase): async def log_out(self) -> bool: """退出当前 864 登录态。""" - await self._request_data("get", "/login/LogOutRequest", timeout=15) + try: + # 不同 864 版本里退出接口命名存在差异: + # 1. 当前项目早期接的是 `/login/LogOutRequest`; + # 2. 用户本地 864 源码中实际注册的是 `/login/LogOut`; + # 3. 因此这里优先尝试较新的 `/LogOut`,失败后再回退到旧路径,降低版本切换成本。 + await self._request_data("get", "/login/LogOut", timeout=15) + except Exception: + await self._request_data("get", "/login/LogOutRequest", timeout=15) return True