From 711213ded8b063b34ab16976dbc018de7b38d193 Mon Sep 17 00:00:00 2001 From: liuwei Date: Mon, 27 Apr 2026 09:19:56 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=A4=B4=E5=83=8F=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=90=8C=E6=AD=A5=E4=B8=8E=E6=B8=85=E7=90=86=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/wechat/contact_manager.py | 79 ++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/utils/wechat/contact_manager.py b/utils/wechat/contact_manager.py index 4210a5d..762ce81 100644 --- a/utils/wechat/contact_manager.py +++ b/utils/wechat/contact_manager.py @@ -147,23 +147,25 @@ class ContactManager: except Exception as exc: self._logger.warning(f"保存头像缓存索引失败: {exc}") - def _delete_avatar_file_by_name(self, file_name: str) -> None: + def _delete_avatar_file_by_name(self, file_name: str) -> bool: """按文件名删除缓存头像,删除失败时只记日志不打断主流程。""" file_name = str(file_name or "").strip() if not file_name or not self._avatar_cache_dir: - return + return False target_path = self._avatar_cache_dir / file_name if not target_path.exists(): - return + return False try: target_path.unlink() + return True except Exception as exc: self._logger.warning(f"删除旧头像缓存失败 file={target_path}: {exc}") + return False - def _cleanup_avatar_cache_files(self) -> None: + def _cleanup_avatar_cache_files(self) -> Dict[str, int]: """清理缓存目录里的孤儿头像文件与残留临时文件。""" if not self._avatar_cache_dir: - return + return {"orphan_deleted": 0, "tmp_deleted": 0} # 允许保留 manifest 自身,其余不在当前索引里的文件一律视为孤儿文件。 referenced_files = { @@ -172,6 +174,7 @@ class ContactManager: if str(meta.get("file_name") or "").strip() } manifest_name = self._avatar_manifest_path.name if self._avatar_manifest_path else "" + cleanup_stats = {"orphan_deleted": 0, "tmp_deleted": 0} for file_path in self._avatar_cache_dir.iterdir(): if not file_path.is_file(): @@ -180,10 +183,13 @@ class ContactManager: continue # `.tmp` 文件说明上次下载未完整落盘,直接清掉,避免长时间堆积。 if file_path.suffix.lower() == ".tmp": - self._delete_avatar_file_by_name(file_path.name) + if self._delete_avatar_file_by_name(file_path.name): + cleanup_stats["tmp_deleted"] += 1 continue if file_path.name not in referenced_files: - self._delete_avatar_file_by_name(file_path.name) + if self._delete_avatar_file_by_name(file_path.name): + cleanup_stats["orphan_deleted"] += 1 + return cleanup_stats def _guess_avatar_extension(self, avatar_url: str, content_type: str) -> str: """推断头像文件扩展名,尽量保留真实图片类型。""" @@ -243,6 +249,16 @@ class ContactManager: return manifest_changed = False + # 统计字段用于打 debug 汇总日志,方便观察“初始化第一批头像”时到底发生了什么: + # 1. reuse_count 表示命中本地缓存、无需下载; + # 2. download_count 表示本轮真正新增或重拉的头像数量; + # 3. replaced_count 表示头像 URL 变更导致发生了“新旧替换”; + # 4. removed_contact_count / removed_file_count 表示联系人消失后同步移除了多少记录和文件。 + reuse_count = 0 + download_count = 0 + replaced_count = 0 + removed_contact_count = 0 + removed_file_count = 0 with self._avatar_cache_lock: for wxid, avatar_url in self._head_images.items(): remote_url = str(avatar_url or "").strip() @@ -253,31 +269,55 @@ class ContactManager: cached_path = self.get_cached_head_image_path(wxid) # 只有“URL 变了”或“本地文件丢了”才重新下载,避免刷新通讯录时重复打远端。 if manifest_item.get("remote_url") == remote_url and cached_path: + reuse_count += 1 continue downloaded_path = self._download_avatar_to_cache(wxid, remote_url) if not downloaded_path: + self._logger.debug(f"头像缓存同步跳过 wxid={wxid} reason=download_failed") continue self._avatar_manifest[wxid] = { "file_name": Path(downloaded_path).name, "remote_url": remote_url, } + download_count += 1 # 同一联系人头像地址变化后,旧文件已经失去引用,这里立刻删掉旧版本。 if old_file_name and old_file_name != Path(downloaded_path).name: - self._delete_avatar_file_by_name(old_file_name) + if self._delete_avatar_file_by_name(old_file_name): + removed_file_count += 1 + replaced_count += 1 + self._logger.debug( + f"头像缓存已替换 wxid={wxid} old={old_file_name} new={Path(downloaded_path).name}" + ) + else: + self._logger.debug(f"头像缓存已下载 wxid={wxid} file={Path(downloaded_path).name}") manifest_changed = True # 把已经不在通讯录中的头像记录清理掉,避免 manifest 无限增长。 removed_wxids = [wxid for wxid in self._avatar_manifest.keys() if wxid not in self._head_images] for wxid in removed_wxids: removed_meta = self._avatar_manifest.pop(wxid, None) or {} - self._delete_avatar_file_by_name(str(removed_meta.get("file_name") or "").strip()) + removed_contact_count += 1 + if self._delete_avatar_file_by_name(str(removed_meta.get("file_name") or "").strip()): + removed_file_count += 1 manifest_changed = True if manifest_changed: self._save_avatar_manifest() # 无论本轮 manifest 是否有变化,都顺手做一次目录对账, # 保证历史异常中断留下的孤儿文件也能逐步被回收。 - self._cleanup_avatar_cache_files() + cleanup_stats = self._cleanup_avatar_cache_files() + self._logger.debug( + "头像缓存同步完成 " + f"total_head_images={len(self._head_images)} " + f"manifest_entries={len(self._avatar_manifest)} " + f"reuse={reuse_count} " + f"downloaded={download_count} " + f"replaced={replaced_count} " + f"removed_contacts={removed_contact_count} " + f"removed_files={removed_file_count} " + f"orphan_deleted={cleanup_stats.get('orphan_deleted', 0)} " + f"tmp_deleted={cleanup_stats.get('tmp_deleted', 0)}" + ) def get_cached_head_image_path(self, wxid: str) -> str: """返回头像缓存本地路径,若缓存不存在则返回空字符串。""" @@ -297,8 +337,10 @@ class ContactManager: # 如果本地已有缓存,且当前头像 URL 没变,就直接复用; # 如果 URL 已变化,则继续往下重拉新头像,避免页面一直展示旧图。 if cached_path and (not remote_url or manifest_item.get("remote_url") == remote_url): + self._logger.debug(f"头像缓存命中 wxid={wxid} file={Path(cached_path).name}") return cached_path if not remote_url: + self._logger.debug(f"头像缓存缺失 wxid={wxid} reason=no_remote_url") return "" with self._avatar_cache_lock: @@ -307,19 +349,32 @@ class ContactManager: old_file_name = str(manifest_item.get("file_name") or "").strip() cached_path = self.get_cached_head_image_path(wxid) if cached_path and manifest_item.get("remote_url") == remote_url: + self._logger.debug(f"头像缓存命中 wxid={wxid} file={Path(cached_path).name}") return cached_path downloaded_path = self._download_avatar_to_cache(wxid, remote_url) if not downloaded_path: + self._logger.debug(f"头像缓存补下载失败 wxid={wxid}") return "" self._avatar_manifest[wxid] = { "file_name": Path(downloaded_path).name, "remote_url": remote_url, } # 单头像补下载时同样清掉旧版本,避免访问链路把目录越堆越大。 + removed_file_count = 0 if old_file_name and old_file_name != Path(downloaded_path).name: - self._delete_avatar_file_by_name(old_file_name) + if self._delete_avatar_file_by_name(old_file_name): + removed_file_count += 1 self._save_avatar_manifest() - self._cleanup_avatar_cache_files() + cleanup_stats = self._cleanup_avatar_cache_files() + self._logger.debug( + "头像缓存补下载完成 " + f"wxid={wxid} " + f"file={Path(downloaded_path).name} " + f"replaced={'Y' if bool(old_file_name and old_file_name != Path(downloaded_path).name) else 'N'} " + f"removed_files={removed_file_count} " + f"orphan_deleted={cleanup_stats.get('orphan_deleted', 0)} " + f"tmp_deleted={cleanup_stats.get('tmp_deleted', 0)}" + ) return downloaded_path def get_head_image_version(self, wxid: str) -> str: