# 2026-08-21 工作日志 ## 企业微信自动回复第二套方案(引擎 B 数据直读)落地 ### 背景 用户确认 4 个拍板点:发送层 GUI 复用、数据来源两条线并行、机器人对接复用 ai_chat、互斥共存。 ### 新建文件 - `send_lock.py`:发送互斥锁。Windows 命名互斥量(`Global\WeChatBotSendLock`)+ 线程持有者跟踪(防同线程重入);非 Windows 回退 threading.Lock。`try_acquire(owner, timeout)` / `release()` / `lock()` 上下文管理器。 - `engine_b.py`:引擎 B(数据直读)。`DataEngine` 类独立线程轮询 `conversations.json`,按 history 增量发现新客户消息,经 `bot.enqueue_detected()` 投递共享队列。启动时 120s 内新消息算新鲜可投,防抖窗口 300s。纯检测纯投递,不碰窗口。 - `test_dual_engine.py`:双引擎集成测试(31 项断言),覆盖互斥锁/投递/字段透传/去重/合并/DataEngine 集成。回归命令:系统 Python 3.11 跑(wechat_bot 依赖 win32gui,managed python 无此包)。 ### 修改文件 - `wechat_bot.py`: - 队列条目新增 5 字段:`dedup_key`/`detected_by`/`detection_ts`/`sending_owner`/`send_lock_ts`,在 `_load_pending_replies` 与 `_persist_pending_replies_unlocked` 两处白名单透传(旧条目自动补默认值,44 字段兼容验证通过)。 - `send_reply()` 改为互斥包装:`try_acquire` 拿不到锁 → `_deny_send("另一引擎正在发送")`;原主体改名 `_send_reply_inner`。 - 新增 `enqueue_detected()`(B 引擎投递,复用 `_mark_reply_pending` + 补新字段 + display_name 兜底)、`has_active_pending()`(终态集合判断去重)、`merge_detected_by()`、`_merge_engine_tags()`。 - `gui_runtime.py`:`BotThread.__init__` 加 `enable_engine_b`/`engine_b_poll_interval`;`run()` 中 bot connect 成功后启动 DataEngine 线程,finally/stop 中停止。 - `wechat_gui_qt.py` / `wechat_gui.py`:runtime 默认值加 `enable_engine_b=True`、`engine_b_poll_interval=2.0`,start_monitoring 传参。 - `app_settings.json`:加 `enable_engine_b: true`、`engine_b_poll_interval: 2.0`。 ### 关键架构决策 - **B 引擎不自己发送**:队列条目由引擎 A 的 `_resume_orphaned_pending_reply` 统一捡起(重新定位→视觉提取→`_generate_ai_reply`→`send_reply`)。发送天然收敛到唯一通道 + 互斥锁,B 全程不碰窗口。这是"检测解耦、发送收敛"的最稳落地。 - 检测到 A 已处理的会话时 B 只补 detected_by 标记,不重复投递。 - 队列字段是白名单读写,新增字段必须在 load/persist 两处透传,否则 B 引擎的标记会被 A 持久化时抹掉。 ### 验证 - `test_dual_engine.py`:31 项全部通过。 - `test_runtime_settings.py` 18 项、`test_session_name.py` 67 项回归通过。 - 旧 pending_replies.json 条目加载兼容(新字段自动补默认值)。 --- ## Phase 2:企微本地库解密(密钥提取攻坚) ### 关键发现(本轮) 1. **真正的 bug 根因**:`wxwork_key.py::_prep_verify` 的验证基准构造错误——读了文件 offset 16..32(含明文头),正确应为 `8..16 密文A + 24..32 密文B`(与 `wxwork_crypto.decrypt_page1_block` 一致)。修复后探针测试从"0 命中"变为"命中"。 2. **算法确认**:`WXWork.exe`(269MB,`D:\soft\WXWork\`,版本 5.0.10.6015)代码段含 `sAlT` 常量 + MD5 初始常量 `0x67452301` 的机器码(`mov [ebp-0xC],0x546C4173` / `mov [ebp-0x7C],0x67452301`)→ 证实 wxSQLite3 aes128cbc MD5+sAlT 派生算法判断正确。 3. **扫描器性能优化**:`regions(rw_only=True)` 只扫可写区 + `len(set(w))>=8` 字节多样性预过滤 + `_fast_verify` 单变体优先验证(AES 3→1 次)。探针扫描 87.7s → 3.4s。 4. **扫描覆盖全部**:16/32 字节窗口 × 16/8 字节对齐 × 全部企微进程(含 WeChatAppEx.exe 8 实例、WXDrive_x64.exe、WXWorkXNet.exe),3 轮全 0 命中 → **结论:企微 5.0.10 数据库密钥不在常驻可读内存**(用完即焚/受保护页/混淆存储)。 5. 当前进程非管理员权限(IsUserAnAdmin=False)。 ### 新建/修改文件 - `wxwork_key.py`:修复 `_prep_verify`;`regions()` 加 `rw_only` 参数;`scan_binary_single` 加 `key_len`/`step` 参数;`scan_binary_all` 支持 step;`find_wxwork_pids` 加 `include_extra`(WeChatAppEx/WXDrive);CLI 加 `--binary8`(8 字节对齐)。 - `test_scan_probe.py`:修复子进程 code 路径 `\U` 转义(用 `!r`),加子进程存活检查;改为 aligned 优先。 - `test_scan_probe_mis.py`(新):非对齐密钥探针,验证全滑窗兜底。 - `scan_golden_window.py`(新):重启企微黄金窗口多轮扫描脚本(`--kill` 需用户确认)。 - `probe_wxwork_db.py`:已就绪(等密钥到手探查 message.db 表结构)。 ### 下一步(待用户决策) - A. 黄金窗口扫描:重启企微后立即多轮扫(`scan_golden_window.py`,需用户同意重启) - B. 管理员权限重跑全部扫描 - C. hook sqlite3_key(看雪方案,注入拦截,开发量大) - D. 换会话存档 SDK 路线(`Downloads\wxwork-finance-sdk-main`,企业后台 API) --- ## Phase 2 落地:参考目录代码 → DB 直读(密钥已验证,主线打通) ### 背景 用户提供参考目录 `C:\Users\pc\WorkBuddy\2026-08-19-18-27-34\`(企业微信导出助手,密钥已验证),要求以它为基准实现 DB 直读,保留密钥逻辑、复用可靠模块。 ### 参考目录关键资产(已逐文件分析) - `wxwork_keys.json`:2 个账号验证过密钥(`1688856770803435`/`db7288a302bfbe66563d81f025bde977`、`1688855555743233`/`d18e7f9a222665389989066f929ece19`)→ **密钥来源问题解决,内存提取不再必要** - `wxwork_crypto.py`:解密算法与本项目 `wxwork_crypto.py` 完全一致(互相印证) - `wxwork_find_key.py`:7 种内存密钥提取模式(参考项目曾在运行中企微成功提取,但本项目 5.0.10 提取不到) - `wxwork_export_final.py`:`parse_content`(UTF-8→protobuf 递归→JSON→hex)、`MSG_TYPE_MAP`、`is_personal_chat`(M:/S: 白名单)、`clean_text`、`format_timestamp`、`load_keys`(支持 `*` 通配)、`decrypt_with_keys`(WAL 感知增量缓存)、`detect_wxwork_dir`、会话名解析 - `wxwork_export_db.py`:`collect_metadata`(user_table/conversation_table → user_cache/conv_cache) - `crm_import/decrypted/`:9 账号目录全空 → 印证仅 2 账号有密钥 ### 新建/修改文件 - `wxwork_keys.json`(新):复制参考密钥,含 note 说明"每账号独立密钥,未列出账号未登录" - `wxwork_db.py`(重写): - 移植参考全套:`load_keys`/`decrypt_with_keys`/`parse_content`/`extract_protobuf_texts`/`clean_text`/`MSG_TYPE_MAP`/`get_msg_type_name`/`is_personal_chat`/`is_blocked_conv_name`/`format_timestamp`/`detect_wxwork_dir`/`connect_sqlite`/会话名解析(user_cache/conv_cache/S: 对端推导) - **WAL 帧合并增强**(参考实现没有):企微运行时新消息在 message.db-wal,参考只靠 wal_mtime 触发重解密主库(仍缺 WAL 内消息);本项目直接解密 WAL 帧(同算法、帧头明文、salt 匹配过滤、后帧覆盖)按页号合并进主库副本,更新 SQLite 头页数字段,`PRAGMA quick_check` 验证(`test_wal_merge.py`) - **会话指纹增强**:`session_fp_from_name` 用与 `wechat_bot._fp_from_name` 完全一致的算法(md5+blake2b,40 字节)→ DB 直读 fp_hex 与引擎 A identity_by_name 模式同指纹空间 - `normalize_name` 仅标准库实现(NFKC+去空白),避免 import session_name(其顶层依赖 numpy) - `WXWorkDB` 类:构造时解密缓存+元数据,`get_new_messages(since_ts)` 秒级 send_time 增量查询,`_refresh_if_needed` 按 mtime 自动重解密 - `engine_b.py`(改造):`DataEngine` 加 `db_source` 参数(WXWorkDB 实例);`_run` 有 DB 则 `_poll_db_once`;DB 直读失败自动回退 `poll_once`(conversations.json),下轮自动重试恢复;DB 路径**按 fp_hex 聚合**只投递每会话最后一条(防历史逐条误投);初始游标 = now-120s(与启动新鲜窗口一致);`_process_entry` 提取为两条路径共用 ### 验证结果 - `wxwork_db.py` 冒烟:2 账号可读(`1688856770803435`+`1688855555743233`)、1041 条单聊消息、health_check 通过 - 增量查询命中真实消息:今天 11:06:05「一个小迷糊: 在不」 - 指纹一致性:`' 高瑞 @微信 '` 的 fp 前 16 字节与 conversations.json 档案 key 完全一致 → 引擎 A 正运行在 identity_by_name 模式 - WAL 合并:quick_check ok(当前 WAL 无更新帧,机制已验证) - 引擎 B 真实集成:FakeDB 聚合单测通过(多历史只投最新一条/self 过滤/去重/降级回退) - `test_dual_engine.py`:31 项全部通过(系统 Python 3.11 跑) - 密钥匹配验证:仅 2 账号可解密,其余 9 账号无密钥(与参考项目空目录印证) ### 关键结论 - 密钥确认在主库页 1 可验证(`verify_key` 0.03s),企微运行时 WAL 帧同算法加密可合并 → 实时性方案成立 - DB 直读 fp_hex 与引擎 A 档案同指纹空间 → 投递后可被引擎 A 无缝定位窗口回复 - 回归命令:managed venv(`C:\Users\pc\.workbuddy\binaries\python\envs\default\Scripts\python.exe`)跑 wxwork_db/engine_b;系统 Python 3.11 跑 test_dual_engine(wechat_bot 依赖 win32gui) --- ## 引擎 B 数据源改为「两路并行双跑」(用户拍板) ### 背景 用户质疑"是否有两套方案兜底,而不是合到一起了"。原实现 DB 与 JSON 是**主备二选一**(`_run` 有 DB 只跑 `_poll_db_once`,DB 异常才 `return self.poll_once()` 降级)——DB 正常时 JSON 路径完全不执行,存在单点盲区。用户选择改为**并行双跑**。 ### 修改(engine_b.py) - `_run`:每轮无条件调用 `_poll_db_once()` + `poll_once()`(两路独立检测,互为兜底) - 游标分离:新增 `_seen_db`(DB 路径会话游标),`_seen` 只归 JSON 路径;`_process_entry` 加 `seen` 参数(DB 传 `_seen_db`,JSON 用默认),两路独立建档/判定、互不污染 - `_poll_db_once`:去掉降级 return,DB 失败仅 `db_active=False` 并打印,JSON 路径不受影响 - 去重统一走 `_maybe_enqueue` 的 dedup_key(fp_hex:时间戳,300s 防抖 + 队列级去重)——同一条消息两路都发现也只投一次 ### 验证 - 引擎 B 冒烟通过;并行单测全部断言通过:同消息只投一次 / 两路各自增量 / 游标独立 / DB 崩溃不影响 JSON - 真实 DB 并行集成:db_active=True、0 重复投递 - `test_dual_engine.py`:31 项全过 ### 架构语义(最终形态) 引擎 A(截图 RPA)与引擎 B(数据直读)两套独立检测通道;引擎 B 内部 DB 直读 + conversations.json **并行双跑**互为兜底;发送统一收敛引擎 A send_reply + send_lock 互斥。 --- ## GUI 引擎切换卡片 + 全量回归(交付) ### GUI 改动(wechat_gui_qt.py) - `DashboardPage` 加「检测引擎」卡片:引擎 A 开关(`engine_a_check`)、引擎 B 开关(`engine_b_check`)、引擎 B 数据源三选一(`QRadioButton` 并行双跑/仅DB/仅JSON);引擎 B 关闭时数据源单选组置灰(`_sync_engine_b_source_enabled`) - 信号:复用已有 `settingsChanged` 信号 → `MainWindow._on_dashboard_engine_settings`(合并进 runtime_settings + 持久化) - 键名统一为 **`engine_a_enabled`**(注意不是 `enable_engine_a`!BotThread 参数、_runtime_defaults、_load_runtime_settings 校验、start_monitoring 传参、DashboardPage 读写全部用 `engine_a_enabled`);`enable_engine_b`、`engine_b_data_source` 不变 - `_load_runtime_settings` 校验:`engine_a_enabled`/`enable_engine_b` 转 bool、`engine_b_data_source` 白名单(非法回落 parallel) ### 运行时改动(gui_runtime.py) - `BotThread.__init__` 加 `engine_a_enabled=True`、`engine_b_data_source="parallel"`(含合法性回落) - 引擎 B 启动段:按数据源模式决定是否接 DB(parallel/db 才初始化 WXWorkDB,失败仅记日志 db_source=None) - 主循环:`bot._poll_once(scan_unread=self.engine_a_enabled)`(引擎 A 关闭→跳过红点扫描,只留队列恢复,B 投递仍能发送) ### 修复的真实 bug - `engine_b.py::_poll_db_once`:DB 故障恢复后若首轮查询为空,`db_active` 永不回 True(`if not msgs: return` 跳过置位)→ 查询成功即置 `db_active=True`(空返回前) ### 验证 - 语法:5 核心模块 py_compile 通过 - GUI 卡片冒烟(`tmp/qt_engine_card_smoke.py`,offscreen):18 项全过(初始态/信号键集合/radio 置灰联动/设置加载校验/合并保存持久化) - 引擎 B 三模式单测(`tmp/test_engine_b_modes.py`):14 项全过(json 只跑 JSON、db 只跑 DB、db 无源退化 JSON、parallel 双跑、DB 异常隔离+恢复、非法模式回落) - 回归:`test_dual_engine.py` 31 项、`test_runtime_settings.py` 18 项、`test_qt_console_bridge.py` 14 项、`test_qt_capsule.py` 19 项全过 - **测试适配**:`test_runtime_settings.py` 的 mock `poll_once()` 改为 `poll_once(scan_unread=True)`(gui_runtime 新调用契约) ### 运行语义提示 - GUI 改引擎开关:运行中修改→保存文件,**本轮监听结束后重启才完全生效**(BotThread 启动时读取) - 引擎 A 关闭 ≠ 不回复:B 投递的消息由 A 的队列恢复通道照常定位发送,只是不再截图扫红点 --- ## 修复:视觉定位"卡死"(bot 线程假死) ### 现象 用户截图显示任务卡在"正在视觉定位"(opening),日志在 `_find_next_unread_session` 检测到总未读后完全中断,bot 线程约 4-5 分钟无响应。 ### 根因 `_scroll_session_list_page` 滚动到底后,由于时间戳/动画等微小像素差异,`_session_page_signature` 认为页面不同,返回 `after` 而非 `None`。导致: - `_find_pending_session` / `_find_next_unread_session` 持续翻页扫描 240 页(`SESSION_SCAN_MAX_PAGES`)或 45 秒超时 - 4 个 pending 恢复任务 × 45 秒 = 3 分钟+/轮 - 每轮结束后冷却期短(`SESSION_SCAN_COOLDOWN_SECONDS=15s`,失败按 `2^failures` 退避),任务反复被扫描 - 日志长时间无输出,GUI 状态停滞,用户感知为"卡死" ### 修复(wechat_bot.py) 1. `_session_pages_overlap_at_shift`:放宽 `shift` 边界检查从 `<=0` 到 `<0`,允许 `shift=0` 检测(同页内容完全匹配) 2. 新增 `_any_scroll_overlap(before, after)`:在 `0 ~ 2*item_h` 范围内遍历合理 shift,检测是否存在 >=90% 的内容重叠 3. `_scroll_session_list_page`:签名比较后增加 `_any_scroll_overlap` 兜底——如果签名不同但无有效滚动重叠,返回 `None`(认为到底),阻止无效翻页 ### 验证 - 语法 py_compile 通过 - `test_dual_engine.py` 31 项、`test_engine_b_modes.py` 14 项全过 - `tmp/test_scroll_deadlock_fix.py`: - 同页 shift=0 返回 True ✓ - 时间戳微差(签名不同但无有效滚动)→ `_any_scroll_overlap` 返回 True → `_scroll_session_list_page` 会返回 None ✓ - 真实滚动 72px → `_any_scroll_overlap` 返回 True,滚动仍被识别 ✓ ### 影响 - 修复后:滚动到底立即返回 None,不再做 45 秒无效扫描 - 极少量场景(动画/加载导致大面积像素变化)可能提前结束扫描,遗漏极底部未读——但相比卡死 3 分钟+,可接受;下一轮轮询会重新扫描 --- ## 修复:队列任务永远"处理中"(快速放弃机制) ### 现象 用户截图:3 个任务(一个小迷糊@微信/在线问诊/高瑞)全部 stage=opening、resume_failures=7/3/2,监听停止后 GUI 仍显示"处理中"。用户诉求:AI 回复完成就应移除任务,除非有新消息。 ### 根因 - 发送成功(sent)任务本就会 `_clear_reply_pending` 移除——但**卡在 opening 的任务没有快速放弃机制** - `_UNREACHABLE_RESUME_FAILURES=8`:纯定位任务要失败 8 次才丢弃,加上 `2^failures` 指数退避(最长 600s),最坏情况任务挂几小时 - 已生成 AI 回复(staged_reply_text 非空)却发不出去的任务,回复有时效性,继续挂着毫无意义 ### 修复(wechat_bot.py) 1. 新增 `_STAGED_REPLY_EXPIRE_FAILURES = 3`:有 staged_reply_text 的任务失败 3 次即放弃(回复未发出、不落档,客户新消息会重新建单) 2. `_expire_unreachable_pending` 按是否有 staged_reply_text 选阈值:有回复→3,纯定位→8(保留原语义,防企微暂未开误删) 3. 放弃时打印区分原因(有过时回复 / 找不到会话) ### 验证 - 语法 py_compile 通过 - `tmp/test_pending_expire.py` 7 项全过(staged 3 次放弃/2 次保留、纯定位 8/7 边界、放弃不落档、非法值与空白回复边界) - 回归:`test_dual_engine.py` 31 项、`test_engine_b_modes.py` 14 项、`test_runtime_settings.py` 18 项全过 ### 用户队列现状(修复后重启监听即生效) - 一个小迷糊@微信(failures=7,有回复)→ 下次失败即达 3 → 放弃 - 在线问诊(failures=3)→ 立即达阈值 → 放弃 - 高瑞(failures=2)→ 再失败 1 次 → 放弃 --- ## 任务池闭环验证(发送成功移除 + 新消息重新投递) 用户确认诉求:AI 回复成功→任务移除;对方新消息→算新任务重新入队,任务池顺畅。 ### 闭环语义(全部已确认成立) - **发送成功(sent)→ `_clear_reply_pending` 移除任务**(`_resume_orphaned_pending_reply` 发送对账路径) - **新消息 = 新 dedup_key**:`dedup_key = fp_hex:消息ts`,DB 消息在 `_poll_db_once` 归一化为 `{"content", "ts"}`(ts=send_time),新消息 ts 不同 → 不撞防抖 - **`_recently` 300s 防抖只挡同一条消息**(同 dedup_key),新消息不受影响 - **`has_active_pending` 对终态(sent/cancelled/expired/cleared)返回 False** → 任务移除后引擎 B 可重新投递 - **队列已有活跃任务时引擎 B 只补 detected_by 标记不重复投递** ### 验证 - `tmp/test_queue_loop.py` 10 项全过:投递→防抖→发送移除→新消息重新投递→二次防抖→活跃不重复 - 注意:测试数据字段用 `ts`(不是 `send_time`),`_msg_ts` 读 `msg["ts"]` - 回归:`test_dual_engine.py` 31、`test_engine_b_modes.py` 14、`test_pending_expire.py` 7 全过