优化社交图展示并为通讯录接入本地头像缓存
This commit is contained in:
@@ -3,10 +3,16 @@
|
||||
联系人管理器 - 提供全局访问联系人信息的单例类
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from typing import Dict, Optional, List, Tuple
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from utils.json_converter import json_to_object
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class ContactManager:
|
||||
@@ -23,6 +29,10 @@ class ContactManager:
|
||||
_friends: List[str] = []
|
||||
_group_members: List[Dict] = []
|
||||
_group_contacts_friends: Dict[str, Dict[str, str]] = {}
|
||||
_avatar_cache_lock = threading.RLock()
|
||||
_avatar_cache_dir: Optional[Path] = None
|
||||
_avatar_manifest_path: Optional[Path] = None
|
||||
_avatar_manifest: Dict[str, Dict[str, str]] = {}
|
||||
# 定义公共好友列表
|
||||
_PUBLIC_FRIENDS = {
|
||||
'fmessage': '朋友推荐消息',
|
||||
@@ -43,6 +53,11 @@ class ContactManager:
|
||||
# 确保初始化代码只执行一次
|
||||
if not ContactManager._initialized:
|
||||
self._logger.info("初始化联系人管理器")
|
||||
# 头像缓存采用“本地文件 + manifest 索引”的方式:
|
||||
# 1. 本地文件用于后台页面与服务端渲染复用,避免每次都走远端头像链接;
|
||||
# 2. manifest 记录 wxid、头像源地址与本地文件名,重启后也能继续命中缓存;
|
||||
# 3. 后续只在头像 URL 变化时重新下载,避免每次刷新通讯录都全量拉取头像。
|
||||
self._init_avatar_cache()
|
||||
ContactManager._initialized = True
|
||||
|
||||
@classmethod
|
||||
@@ -74,10 +89,197 @@ class ContactManager:
|
||||
self._friends = friends
|
||||
self._head_images = head_imgs
|
||||
self._group_members = chatroom_members
|
||||
# 通讯录刷新后顺手做一次头像缓存增量同步:
|
||||
# 1. 只处理新增头像或 URL 已变化的联系人;
|
||||
# 2. 不改动业务侧原有头像 URL 存储方式,避免影响其他调用链;
|
||||
# 3. 让后台展示和后续图片渲染都能尽量命中本地缓存。
|
||||
self._sync_avatar_cache()
|
||||
self._logger.info(f"联系人信息已更新,共 {len(contacts)} 个联系人")
|
||||
# 分类联系人
|
||||
self._classify_contacts()
|
||||
|
||||
def _init_avatar_cache(self) -> None:
|
||||
"""初始化头像缓存目录和 manifest。"""
|
||||
cache_dir = Path(__file__).resolve().parents[2] / "temp" / "contact_avatars"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._avatar_cache_dir = cache_dir
|
||||
self._avatar_manifest_path = cache_dir / "avatar_manifest.json"
|
||||
self._load_avatar_manifest()
|
||||
|
||||
def _load_avatar_manifest(self) -> None:
|
||||
"""加载头像缓存索引,只保留本地文件仍存在的记录。"""
|
||||
if not self._avatar_manifest_path or not self._avatar_manifest_path.exists():
|
||||
self._avatar_manifest = {}
|
||||
return
|
||||
try:
|
||||
data = json.loads(self._avatar_manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
self._logger.warning(f"加载头像缓存索引失败,将重新建立缓存: {exc}")
|
||||
self._avatar_manifest = {}
|
||||
return
|
||||
|
||||
normalized: Dict[str, Dict[str, str]] = {}
|
||||
for wxid, meta in (data or {}).items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
file_name = str(meta.get("file_name") or "").strip()
|
||||
remote_url = str(meta.get("remote_url") or "").strip()
|
||||
if not file_name or not remote_url:
|
||||
continue
|
||||
local_path = self._avatar_cache_dir / file_name if self._avatar_cache_dir else None
|
||||
if not local_path or not local_path.exists():
|
||||
continue
|
||||
normalized[wxid] = {
|
||||
"file_name": file_name,
|
||||
"remote_url": remote_url,
|
||||
}
|
||||
self._avatar_manifest = normalized
|
||||
|
||||
def _save_avatar_manifest(self) -> None:
|
||||
"""持久化头像缓存索引,保证重启后还能复用已经下载好的头像。"""
|
||||
if not self._avatar_manifest_path:
|
||||
return
|
||||
try:
|
||||
self._avatar_manifest_path.write_text(
|
||||
json.dumps(self._avatar_manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.warning(f"保存头像缓存索引失败: {exc}")
|
||||
|
||||
def _guess_avatar_extension(self, avatar_url: str, content_type: str) -> str:
|
||||
"""推断头像文件扩展名,尽量保留真实图片类型。"""
|
||||
guessed_from_type = mimetypes.guess_extension((content_type or "").split(";")[0].strip()) or ""
|
||||
if guessed_from_type in {".jpe", ".jpeg", ".jpg", ".png", ".gif", ".webp", ".bmp"}:
|
||||
return ".jpg" if guessed_from_type == ".jpe" else guessed_from_type
|
||||
parsed_path = Path(urlparse(str(avatar_url or "")).path)
|
||||
suffix = parsed_path.suffix.lower().strip()
|
||||
if suffix in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}:
|
||||
return suffix
|
||||
return ".jpg"
|
||||
|
||||
def _build_avatar_file_name(self, wxid: str, avatar_url: str, extension: str) -> str:
|
||||
"""构造稳定文件名,让同一联系人头像 URL 变化后可以自然生成新文件。"""
|
||||
wxid_hash = hashlib.sha1(str(wxid).encode("utf-8")).hexdigest()[:16]
|
||||
url_hash = hashlib.sha1(str(avatar_url).encode("utf-8")).hexdigest()[:16]
|
||||
return f"{wxid_hash}_{url_hash}{extension}"
|
||||
|
||||
def _download_avatar_to_cache(self, wxid: str, avatar_url: str) -> Optional[str]:
|
||||
"""下载头像到本地缓存目录并返回本地文件路径。"""
|
||||
avatar_url = str(avatar_url or "").strip()
|
||||
if not avatar_url or not self._avatar_cache_dir:
|
||||
return None
|
||||
|
||||
try:
|
||||
response = requests.get(avatar_url, stream=True, timeout=12)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
self._logger.debug(f"下载头像失败 wxid={wxid}: {exc}")
|
||||
return None
|
||||
|
||||
extension = self._guess_avatar_extension(avatar_url, response.headers.get("Content-Type", ""))
|
||||
file_name = self._build_avatar_file_name(wxid, avatar_url, extension)
|
||||
target_path = self._avatar_cache_dir / file_name
|
||||
temp_path = target_path.with_suffix(f"{target_path.suffix}.tmp")
|
||||
try:
|
||||
with temp_path.open("wb") as file_obj:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
file_obj.write(chunk)
|
||||
temp_path.replace(target_path)
|
||||
except Exception as exc:
|
||||
self._logger.warning(f"写入头像缓存失败 wxid={wxid}: {exc}")
|
||||
try:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
finally:
|
||||
response.close()
|
||||
return str(target_path)
|
||||
|
||||
def _sync_avatar_cache(self) -> None:
|
||||
"""按当前头像 URL 增量同步本地缓存。"""
|
||||
if not self._head_images:
|
||||
return
|
||||
|
||||
manifest_changed = False
|
||||
with self._avatar_cache_lock:
|
||||
for wxid, avatar_url in self._head_images.items():
|
||||
remote_url = str(avatar_url or "").strip()
|
||||
if not remote_url:
|
||||
continue
|
||||
manifest_item = self._avatar_manifest.get(wxid, {})
|
||||
cached_path = self.get_cached_head_image_path(wxid)
|
||||
# 只有“URL 变了”或“本地文件丢了”才重新下载,避免刷新通讯录时重复打远端。
|
||||
if manifest_item.get("remote_url") == remote_url and cached_path:
|
||||
continue
|
||||
downloaded_path = self._download_avatar_to_cache(wxid, remote_url)
|
||||
if not downloaded_path:
|
||||
continue
|
||||
self._avatar_manifest[wxid] = {
|
||||
"file_name": Path(downloaded_path).name,
|
||||
"remote_url": remote_url,
|
||||
}
|
||||
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:
|
||||
self._avatar_manifest.pop(wxid, None)
|
||||
manifest_changed = True
|
||||
|
||||
if manifest_changed:
|
||||
self._save_avatar_manifest()
|
||||
|
||||
def get_cached_head_image_path(self, wxid: str) -> str:
|
||||
"""返回头像缓存本地路径,若缓存不存在则返回空字符串。"""
|
||||
with self._avatar_cache_lock:
|
||||
meta = self._avatar_manifest.get(wxid) or {}
|
||||
file_name = str(meta.get("file_name") or "").strip()
|
||||
if not file_name or not self._avatar_cache_dir:
|
||||
return ""
|
||||
local_path = self._avatar_cache_dir / file_name
|
||||
return str(local_path) if local_path.exists() else ""
|
||||
|
||||
def ensure_head_image_cached(self, wxid: str) -> str:
|
||||
"""确保指定联系人的头像已缓存到本地,返回本地路径。"""
|
||||
remote_url = str(self._head_images.get(wxid) or "").strip()
|
||||
manifest_item = self._avatar_manifest.get(wxid) or {}
|
||||
cached_path = self.get_cached_head_image_path(wxid)
|
||||
# 如果本地已有缓存,且当前头像 URL 没变,就直接复用;
|
||||
# 如果 URL 已变化,则继续往下重拉新头像,避免页面一直展示旧图。
|
||||
if cached_path and (not remote_url or manifest_item.get("remote_url") == remote_url):
|
||||
return cached_path
|
||||
if not remote_url:
|
||||
return ""
|
||||
|
||||
with self._avatar_cache_lock:
|
||||
# 双重检查避免高并发下重复下载同一张头像。
|
||||
manifest_item = self._avatar_manifest.get(wxid) or {}
|
||||
cached_path = self.get_cached_head_image_path(wxid)
|
||||
if cached_path and manifest_item.get("remote_url") == remote_url:
|
||||
return cached_path
|
||||
downloaded_path = self._download_avatar_to_cache(wxid, remote_url)
|
||||
if not downloaded_path:
|
||||
return ""
|
||||
self._avatar_manifest[wxid] = {
|
||||
"file_name": Path(downloaded_path).name,
|
||||
"remote_url": remote_url,
|
||||
}
|
||||
self._save_avatar_manifest()
|
||||
return downloaded_path
|
||||
|
||||
def get_head_image_version(self, wxid: str) -> str:
|
||||
"""返回头像版本号,用于前端拼接缓存 bust 参数。"""
|
||||
with self._avatar_cache_lock:
|
||||
remote_url = str(self._head_images.get(wxid) or "").strip()
|
||||
meta = self._avatar_manifest.get(wxid) or {}
|
||||
file_name = str(meta.get("file_name") or "").strip()
|
||||
version_source = f"{remote_url}|{file_name}"
|
||||
return hashlib.sha1(version_source.encode("utf-8")).hexdigest()[:12] if version_source.strip("|") else ""
|
||||
|
||||
def _classify_contacts(self) -> None:
|
||||
"""将联系人分类为群组、个人联系人、公共好友和公众号"""
|
||||
self._group_contacts = {}
|
||||
@@ -193,6 +395,9 @@ class ContactManager:
|
||||
对应的头像,如果不存在这返回""
|
||||
"""
|
||||
self._head_images.update({wxid: head_image})
|
||||
# 群成员头像变化通常意味着微信端已经给了新的资源地址,
|
||||
# 这里即时重拉一次本地缓存,保证通讯录和后续渲染尽快拿到最新头像。
|
||||
self.ensure_head_image_cached(wxid)
|
||||
return True
|
||||
|
||||
def get_group_name(self, roomid: str, wxid: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user