diff --git a/admin/dashboard/server.py b/admin/dashboard/server.py index 9248d2d..d99a4e0 100644 --- a/admin/dashboard/server.py +++ b/admin/dashboard/server.py @@ -32,6 +32,28 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '. class DashboardServer: """统计看板服务器""" + @property + def client(self) -> WechatAPIClient | None: + """动态返回当前 robot 上挂载的微信客户端。 + + 说明: + 1. Dashboard 进程通常比 wechat 登录完成更早启动,因此不能只在构造时拍平一份固定引用; + 2. 这里每次访问都优先回看 `robot.ipad_bot`,可自动吃到后续 provider 初始化完成后的真实对象; + 3. 同时保留 `_client` 兜底,兼容未来如果需要在测试里手工注入 mock client 的场景。 + """ + robot = getattr(self, "robot", None) + if robot is not None: + dynamic_client = getattr(robot, "ipad_bot", None) + if dynamic_client is not None: + self._client = dynamic_client + return dynamic_client + return getattr(self, "_client", None) + + @client.setter + def client(self, value: WechatAPIClient | None) -> None: + """允许初始化或测试场景显式覆写当前 client。""" + self._client = value + def __init__(self, host: str = None, port: int = None, username: str = None, password: str = None, robot_instance=None): @@ -93,8 +115,12 @@ class DashboardServer: self.contact_manager = robot_instance.contact_manager self.plugin_manager = robot_instance.plugin_manager self.plugin_registry = robot_instance.plugin_registry - self.client: WechatAPIClient = robot_instance.ipad_bot self.robot = robot_instance + # Dashboard 启动时不再强绑一个“拍平后的 client 引用”: + # 1. wechat 线程可能还在 provider 初始化或登录流程中; + # 2. 这里只先记录一个初始值,后续统一通过 `client` 属性动态读取 robot.ipad_bot; + # 3. 这样后台页在启动竞态下不会因为抢跑而持有一个永远为 None 的旧引用。 + self.client = getattr(robot_instance, "ipad_bot", None) self.member_context_plugin = self.plugin_manager.plugins.get("成员交互摘要") self.member_context_service = getattr(self.member_context_plugin, "service", None) diff --git a/main.py b/main.py index 01e58ad..49737b9 100644 --- a/main.py +++ b/main.py @@ -108,7 +108,11 @@ def main(): # 初始化并启动wechat_ipad客户端 if robot.init_wechat_ipad(): - robot.LOG.info("wechat_ipad客户端启动成功") + # 这里刻意不用“登录成功”措辞: + # 1. 主线程当前只能确认 provider 已成功创建、子线程已进入运行流程; + # 2. 真正的登录成功仍取决于缓存唤醒或扫码流程,日志会在 wechat 线程里继续输出; + # 3. 这样可以避免运维把“线程已启动”误认为“账号已完全在线”。 + robot.LOG.info("wechat_ipad客户端线程已启动,等待登录态完成") else: robot.LOG.error("wechat_ipad客户端启动失败") # 注册定时任务 diff --git a/robot.py b/robot.py index 2d7a537..8750ecb 100644 --- a/robot.py +++ b/robot.py @@ -50,11 +50,21 @@ class Robot: self.LOG.info(f"=" * 50) # wechat_ipad 相关属性 - self.ipad_bot: WechatAPIClient + # 这里先显式给出一个空值: + # 1. Dashboard 可能在 wechat 线程真正跑起来前就读取 `robot.ipad_bot`; + # 2. 若只写类型标注不赋默认值,启动竞态下会直接抛 `AttributeError`; + # 3. 先置为 None 后,其他模块就可以安全地做“是否已就绪”的判定。 + self.ipad_bot: WechatAPIClient | None = None self.ipad_config = None self.ipad_running = False self.ipad_thread = None self.ipad_loop = None + # 启动结果同步事件: + # 1. `init_wechat_ipad()` 在主线程调用,但真正的 provider 初始化在子线程里执行; + # 2. 这里用 Event 把“子线程是否至少成功创建了 provider”回传给主线程; + # 3. 这样主线程就不会再把“线程已启动”误判成“wechat 已成功就绪”。 + self.ipad_startup_event = threading.Event() + self.ipad_startup_error = None self.wxid = None self.nickname = None self.alias = None @@ -217,6 +227,8 @@ class Robot: def init_wechat_ipad(self): """初始化wechat_ipad客户端""" try: + self.ipad_startup_event.clear() + self.ipad_startup_error = None # wechat_ipad 静态配置统一走 Config: # 1. 用户现在只需要维护 `.env` / `config.yaml`,不必再手工维护独立 TOML; # 2. 登录态仍保留本地缓存文件,但只作为运行期状态,不再作为主配置源; @@ -250,6 +262,18 @@ class Robot: ) self.ipad_thread.start() + # 等待子线程至少完成 provider 创建或明确报错: + # 1. 这里不强求“已经登录成功”,否则首次扫码场景会被误判为启动失败; + # 2. 但至少要确认 Gateway / provider 能正常实例化,避免主线程盲目打印成功日志; + # 3. 若超时仍未收到回传,按失败处理,让运维更早感知异常启动。 + startup_ready = self.ipad_startup_event.wait(timeout=15) + if not startup_ready: + self.LOG.error("wechat_ipad客户端初始化超时:未在预期时间内完成 provider 启动") + return False + if self.ipad_startup_error: + self.LOG.error(f"wechat_ipad客户端初始化失败: {self.ipad_startup_error}") + return False + self.LOG.debug("wechat_ipad客户端初始化完成") return True except Exception as e: @@ -272,6 +296,11 @@ class Robot: server_type = str(self.ipad_config.get("server_type", "legacy_855") or "legacy_855").strip() self.ipad_bot = WechatGateway(server_ip, server_port, server_type=server_type) self.message_auto_revoke = MessageAutoRevoke(self.ipad_bot) + # 一旦 provider 已成功创建,就尽快通知主线程: + # 1. 这说明 `server_type`、Gateway 映射和 provider 构造链至少是可用的; + # 2. Dashboard 此时再读取 `robot.ipad_bot` 也不会踩到空对象; + # 3. 后续若登录失败,会由运行时日志和告警继续暴露,而不是伪装成“启动成功”。 + self.ipad_startup_event.set() # 855 provider 现在自行承接运行时模型: # 1. provider 内部负责登录、历史消息拉取、心跳、长心跳、掉线恢复与实时轮询; # 2. Robot 只注册业务回调,继续处理联系人初始化、消息归档、插件调度等项目内逻辑; @@ -291,6 +320,8 @@ class Robot: ) except Exception as e: + self.ipad_startup_error = e + self.ipad_startup_event.set() self.LOG.exception(f"wechat_ipad客户端运行出错: {e}") self.ipad_running = False @@ -352,11 +383,23 @@ class Robot: 2. 后续新增 864 等 provider 时,可以天然形成“每个 provider 自己维护自己的状态”; 3. 这里统一在主程序收口默认路径,避免把路径规则写散到文档、脚本和 provider 内部。 """ - server_type = str(ipad_config.get("server_type", "legacy_855") or "legacy_855").strip().lower() - if not server_type: - server_type = "legacy_855" + server_type = Robot._normalize_wechat_provider_key(ipad_config.get("server_type", "legacy_855")) return os.path.join("wechat_ipad", "providers", server_type, "runtime_state.toml") + @staticmethod + def _normalize_wechat_provider_key(server_type) -> str: + """把对外可配置的 server_type 归一化成 provider 目录键。 + + 说明: + 1. 运行入口允许用户写 `legacy_855` / `855` / `859`,这是配置层的易用性; + 2. 但这几种写法本质上都指向同一个 provider,不应该因为别名不同就分裂出多份登录态文件; + 3. 因此这里统一把 855 家族收敛到 `legacy_855`,保证线上切换别名时缓存路径稳定。 + """ + normalized_server_type = str(server_type or "legacy_855").strip().lower() + if normalized_server_type in {"855", "859", "legacy_855"}: + return "legacy_855" + return normalized_server_type or "legacy_855" + def _load_toml_config_if_exists(self, file_path: str) -> dict: """安全读取一个 TOML 文件,缺失或格式异常时回退为空配置。""" normalized_path = str(file_path or "").strip()