优化社交图展示并为通讯录接入本地头像缓存
This commit is contained in:
@@ -4,7 +4,8 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from flask import Blueprint, render_template, jsonify, request, current_app
|
from urllib.parse import quote
|
||||||
|
from flask import Blueprint, render_template, jsonify, request, current_app, redirect, send_file
|
||||||
from .auth import login_required
|
from .auth import login_required
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -242,6 +243,27 @@ def _normalize_recent_message(server, raw_message: dict, chat_type: str, target_
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dashboard_head_images(contact_manager):
|
||||||
|
"""构造后台可直接使用的头像地址映射。
|
||||||
|
|
||||||
|
说明:
|
||||||
|
1. 前端统一访问本蓝图的头像代理接口,这样可以优先命中本地缓存;
|
||||||
|
2. 头像 URL 哈希会拼到查询参数里,头像变更后浏览器会自然拉取最新版本;
|
||||||
|
3. 即便本地缓存暂时不存在,代理接口也还能回退到远端头像地址,不影响页面展示。
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for wxid, remote_url in (contact_manager.get_all_head_images() or {}).items():
|
||||||
|
if not remote_url:
|
||||||
|
result[wxid] = ""
|
||||||
|
continue
|
||||||
|
version = contact_manager.get_head_image_version(wxid)
|
||||||
|
avatar_url = f"/contacts/api/avatar/{quote(str(wxid), safe='')}"
|
||||||
|
if version:
|
||||||
|
avatar_url = f"{avatar_url}?v={version}"
|
||||||
|
result[wxid] = avatar_url
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# 联系人管理页面
|
# 联系人管理页面
|
||||||
@contacts_bp.route('/')
|
@contacts_bp.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -375,7 +397,9 @@ def api_head_images():
|
|||||||
"""获取联系人头像信息API"""
|
"""获取联系人头像信息API"""
|
||||||
try:
|
try:
|
||||||
server = current_app.dashboard_server
|
server = current_app.dashboard_server
|
||||||
head_images = server.contact_manager.get_all_head_images()
|
# 后台页拿到的是“可展示地址”而不是原始远端 URL,
|
||||||
|
# 这样通讯录页会优先读本地缓存,头像变化时也能自动刷新最新版本。
|
||||||
|
head_images = _build_dashboard_head_images(server.contact_manager)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -388,6 +412,27 @@ def api_head_images():
|
|||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/avatar/<path:wxid>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def api_contact_avatar(wxid):
|
||||||
|
"""返回通讯录头像,本地缓存优先,远端地址兜底。"""
|
||||||
|
try:
|
||||||
|
server = current_app.dashboard_server
|
||||||
|
# 先尝试把头像补齐到本地缓存。
|
||||||
|
# 这样页面首次访问某个联系人时,也能顺手把缓存热起来。
|
||||||
|
cached_path = server.contact_manager.ensure_head_image_cached(wxid)
|
||||||
|
if cached_path and os.path.exists(cached_path):
|
||||||
|
return send_file(cached_path, conditional=True, max_age=86400)
|
||||||
|
|
||||||
|
remote_url = str(server.contact_manager.get_head_image(wxid) or "").strip()
|
||||||
|
if remote_url:
|
||||||
|
return redirect(remote_url, code=302)
|
||||||
|
return jsonify({"success": False, "error": "头像不存在"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取联系人头像失败 wxid={wxid}: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@contacts_bp.route('/api/group_members/<roomid>', methods=['GET'])
|
@contacts_bp.route('/api/group_members/<roomid>', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_group_members(roomid):
|
def api_group_members(roomid):
|
||||||
|
|||||||
@@ -669,7 +669,7 @@
|
|||||||
|
|
||||||
<el-dialog title="公共好友详情" :visible.sync="publicDetailDialogVisible" width="50%">
|
<el-dialog title="公共好友详情" :visible.sync="publicDetailDialogVisible" width="50%">
|
||||||
<div class="detail-avatar-wrap">
|
<div class="detail-avatar-wrap">
|
||||||
<el-avatar size="large" :src="currentPublic.small_head_img_url" @error="() => true" class="detail-avatar">
|
<el-avatar size="large" :src="getHeadImage(currentPublic.wxid)" @error="() => true" class="detail-avatar">
|
||||||
<img src="/static/logo.png"/>
|
<img src="/static/logo.png"/>
|
||||||
</el-avatar>
|
</el-avatar>
|
||||||
</div>
|
</div>
|
||||||
@@ -968,6 +968,10 @@
|
|||||||
},
|
},
|
||||||
refreshContacts() { this.loadContactsData(); this.$message.success('联系人数据已刷新'); },
|
refreshContacts() { this.loadContactsData(); this.$message.success('联系人数据已刷新'); },
|
||||||
handleTabClick() { this.currentPage = 1; },
|
handleTabClick() { this.currentPage = 1; },
|
||||||
|
// 通讯录头像统一走后台代理接口:
|
||||||
|
// 1. 优先命中服务端已缓存的本地头像;
|
||||||
|
// 2. 头像更新后会附带版本参数,浏览器不会一直吃旧图;
|
||||||
|
// 3. 代理接口兜底远端地址,因此这里保持简单读取即可。
|
||||||
getHeadImage(wxid) { return this.headImages[wxid] || ''; },
|
getHeadImage(wxid) { return this.headImages[wxid] || ''; },
|
||||||
handleSizeChange(size) { this.pageSize = size; },
|
handleSizeChange(size) { this.pageSize = size; },
|
||||||
handleCurrentChange(page) { this.currentPage = page; },
|
handleCurrentChange(page) { this.currentPage = page; },
|
||||||
|
|||||||
@@ -1394,8 +1394,8 @@ class ValueRankPlugin(MessagePluginInterface):
|
|||||||
cm = ContactManager.get_instance()
|
cm = ContactManager.get_instance()
|
||||||
edge_svg_parts: List[str] = []
|
edge_svg_parts: List[str] = []
|
||||||
# 双向@标签层:
|
# 双向@标签层:
|
||||||
# 1. 每条边中点标注“a->b / b->a”;
|
# 1. 每条边只保留双向次数数字,避免标签信息量过大把图面压得太满;
|
||||||
# 2. 使用白底半透明标签提升可读性,避免与连线重叠难辨识。
|
# 2. 仍保留白底半透明标签,确保在密集连线里也能看清数值。
|
||||||
edge_label_parts: List[str] = []
|
edge_label_parts: List[str] = []
|
||||||
for edge_idx, (a, b, mention_count, score, a_to_b_count, b_to_a_count) in enumerate(selected_edges, start=1):
|
for edge_idx, (a, b, mention_count, score, a_to_b_count, b_to_a_count) in enumerate(selected_edges, start=1):
|
||||||
ax, ay = pos_map[a]
|
ax, ay = pos_map[a]
|
||||||
@@ -1408,8 +1408,7 @@ class ValueRankPlugin(MessagePluginInterface):
|
|||||||
f'stroke="rgba(33, 150, 243, {opacity:.3f})" stroke-width="{stroke_width:.2f}" />'
|
f'stroke="rgba(33, 150, 243, {opacity:.3f})" stroke-width="{stroke_width:.2f}" />'
|
||||||
)
|
)
|
||||||
# 通过字典序固定方向,确保同一条边每次渲染文案方向一致。
|
# 通过字典序固定方向,确保同一条边每次渲染文案方向一致。
|
||||||
# 标签文案改为“昵称 + 双向次数 + 恶搞关系标签”,不再展示 wxid。
|
# 当前标签只保留双向互动数字,同时继续沿法线方向偏移,避免数字压在线条上。
|
||||||
# 为避免标签压在线条上,这里把标签放到“线段法线方向”的偏移位置。
|
|
||||||
mx, my = (ax + bx) / 2.0, (ay + by) / 2.0
|
mx, my = (ax + bx) / 2.0, (ay + by) / 2.0
|
||||||
dx, dy = (bx - ax), (by - ay)
|
dx, dy = (bx - ax), (by - ay)
|
||||||
edge_len = max(math.hypot(dx, dy), 1.0)
|
edge_len = max(math.hypot(dx, dy), 1.0)
|
||||||
@@ -1426,25 +1425,9 @@ class ValueRankPlugin(MessagePluginInterface):
|
|||||||
angle_deg = math.degrees(math.atan2(dy, dx))
|
angle_deg = math.degrees(math.atan2(dy, dx))
|
||||||
if angle_deg > 90 or angle_deg < -90:
|
if angle_deg > 90 or angle_deg < -90:
|
||||||
angle_deg += 180
|
angle_deg += 180
|
||||||
nick_a = cm.get_group_name(group_id, a) or a
|
label_text = f"{int(a_to_b_count)}/{int(b_to_a_count)}"
|
||||||
nick_b = cm.get_group_name(group_id, b) or b
|
|
||||||
|
|
||||||
total_count = int(a_to_b_count) + int(b_to_a_count)
|
|
||||||
diff_count = abs(int(a_to_b_count) - int(b_to_a_count))
|
|
||||||
if total_count >= 12 and diff_count <= 2:
|
|
||||||
relation_tag = "双向奔赴"
|
|
||||||
elif diff_count >= 8 and max(int(a_to_b_count), int(b_to_a_count)) >= 10:
|
|
||||||
relation_tag = "下头互动"
|
|
||||||
elif total_count <= 2:
|
|
||||||
relation_tag = "点头之交"
|
|
||||||
elif int(a_to_b_count) == 0 or int(b_to_a_count) == 0:
|
|
||||||
relation_tag = "疯狂舔狗"
|
|
||||||
else:
|
|
||||||
relation_tag = "互相捧场"
|
|
||||||
|
|
||||||
label_text = f"{nick_a}→{nick_b} {a_to_b_count}/{b_to_a_count} | {relation_tag}"
|
|
||||||
safe_label = html.escape(label_text)
|
safe_label = html.escape(label_text)
|
||||||
label_width = max(140.0, min(360.0, 8.0 * len(label_text) + 34.0))
|
label_width = max(52.0, min(92.0, 10.0 * len(label_text) + 24.0))
|
||||||
edge_label_parts.append(
|
edge_label_parts.append(
|
||||||
f'<g transform="translate({label_x:.1f},{label_y:.1f}) rotate({angle_deg:.1f})">'
|
f'<g transform="translate({label_x:.1f},{label_y:.1f}) rotate({angle_deg:.1f})">'
|
||||||
f'<rect x="-{label_width / 2:.1f}" y="-11" width="{label_width:.1f}" height="22" '
|
f'<rect x="-{label_width / 2:.1f}" y="-11" width="{label_width:.1f}" height="22" '
|
||||||
@@ -1503,17 +1486,17 @@ class ValueRankPlugin(MessagePluginInterface):
|
|||||||
f'{html.escape(str(nick)[:1] or "?")}</text>'
|
f'{html.escape(str(nick)[:1] or "?")}</text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# 节点文案放到节点外侧:
|
# 节点文案继续放在头像外侧,但整体收紧一圈:
|
||||||
# 1. 外圈节点文本沿“从中心指向外部”的方向偏移,减少互相压住;
|
# 1. 用户反馈昵称和头像分得太开,这里缩短外偏移距离;
|
||||||
# 2. 内圈节点仍保持较近距离,保证中心区域不会炸开;
|
# 2. 昵称字号调小,减少节点一多时的横向挤压;
|
||||||
# 3. 文本锚点根据左右半区自动切换,让阅读方向更自然。
|
# 3. 统计行与昵称保持较近距离,让“头像-昵称-数据”看起来是一组。
|
||||||
outward_dx = math.cos(angle)
|
outward_dx = math.cos(angle)
|
||||||
outward_dy = math.sin(angle)
|
outward_dy = math.sin(angle)
|
||||||
label_gap = node_radius + (18.0 if ring_idx == 0 else 28.0 + ring_idx * 4.0)
|
label_gap = node_radius + (11.0 if ring_idx == 0 else 17.0 + ring_idx * 2.0)
|
||||||
title_x = x + outward_dx * label_gap
|
title_x = x + outward_dx * label_gap
|
||||||
title_y = y + outward_dy * label_gap
|
title_y = y + outward_dy * label_gap
|
||||||
info_x = x + outward_dx * (label_gap + 22.0)
|
info_x = x + outward_dx * (label_gap + 12.0)
|
||||||
info_y = y + outward_dy * (label_gap + 22.0)
|
info_y = y + outward_dy * (label_gap + 12.0) + 1.5
|
||||||
if abs(outward_dx) < 0.20:
|
if abs(outward_dx) < 0.20:
|
||||||
anchor = "middle"
|
anchor = "middle"
|
||||||
elif outward_dx > 0:
|
elif outward_dx > 0:
|
||||||
@@ -1522,11 +1505,11 @@ class ValueRankPlugin(MessagePluginInterface):
|
|||||||
anchor = "end"
|
anchor = "end"
|
||||||
node_svg_parts.append(
|
node_svg_parts.append(
|
||||||
f'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
|
f'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
|
||||||
f'font-size="19" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
|
f'font-size="16" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
|
||||||
)
|
)
|
||||||
node_svg_parts.append(
|
node_svg_parts.append(
|
||||||
f'<text x="{info_x:.1f}" y="{info_y:.1f}" text-anchor="{anchor}" '
|
f'<text x="{info_x:.1f}" y="{info_y:.1f}" text-anchor="{anchor}" '
|
||||||
f'font-size="14" fill="#6C7A96">连接{partner_count}人 · 互动{score:.1f}</text>'
|
f'font-size="12" fill="#6C7A96">连接{partner_count} · {score:.1f}</text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
group_title = html.escape(ContactManager.get_instance().get_nickname(group_id) or group_id)
|
group_title = html.escape(ContactManager.get_instance().get_nickname(group_id) or group_id)
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
联系人管理器 - 提供全局访问联系人信息的单例类
|
联系人管理器 - 提供全局访问联系人信息的单例类
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from loguru import logger
|
import hashlib
|
||||||
from typing import Dict, Optional, List, Tuple
|
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:
|
class ContactManager:
|
||||||
@@ -23,6 +29,10 @@ class ContactManager:
|
|||||||
_friends: List[str] = []
|
_friends: List[str] = []
|
||||||
_group_members: List[Dict] = []
|
_group_members: List[Dict] = []
|
||||||
_group_contacts_friends: Dict[str, Dict[str, str]] = {}
|
_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 = {
|
_PUBLIC_FRIENDS = {
|
||||||
'fmessage': '朋友推荐消息',
|
'fmessage': '朋友推荐消息',
|
||||||
@@ -43,6 +53,11 @@ class ContactManager:
|
|||||||
# 确保初始化代码只执行一次
|
# 确保初始化代码只执行一次
|
||||||
if not ContactManager._initialized:
|
if not ContactManager._initialized:
|
||||||
self._logger.info("初始化联系人管理器")
|
self._logger.info("初始化联系人管理器")
|
||||||
|
# 头像缓存采用“本地文件 + manifest 索引”的方式:
|
||||||
|
# 1. 本地文件用于后台页面与服务端渲染复用,避免每次都走远端头像链接;
|
||||||
|
# 2. manifest 记录 wxid、头像源地址与本地文件名,重启后也能继续命中缓存;
|
||||||
|
# 3. 后续只在头像 URL 变化时重新下载,避免每次刷新通讯录都全量拉取头像。
|
||||||
|
self._init_avatar_cache()
|
||||||
ContactManager._initialized = True
|
ContactManager._initialized = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -74,10 +89,197 @@ class ContactManager:
|
|||||||
self._friends = friends
|
self._friends = friends
|
||||||
self._head_images = head_imgs
|
self._head_images = head_imgs
|
||||||
self._group_members = chatroom_members
|
self._group_members = chatroom_members
|
||||||
|
# 通讯录刷新后顺手做一次头像缓存增量同步:
|
||||||
|
# 1. 只处理新增头像或 URL 已变化的联系人;
|
||||||
|
# 2. 不改动业务侧原有头像 URL 存储方式,避免影响其他调用链;
|
||||||
|
# 3. 让后台展示和后续图片渲染都能尽量命中本地缓存。
|
||||||
|
self._sync_avatar_cache()
|
||||||
self._logger.info(f"联系人信息已更新,共 {len(contacts)} 个联系人")
|
self._logger.info(f"联系人信息已更新,共 {len(contacts)} 个联系人")
|
||||||
# 分类联系人
|
# 分类联系人
|
||||||
self._classify_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:
|
def _classify_contacts(self) -> None:
|
||||||
"""将联系人分类为群组、个人联系人、公共好友和公众号"""
|
"""将联系人分类为群组、个人联系人、公共好友和公众号"""
|
||||||
self._group_contacts = {}
|
self._group_contacts = {}
|
||||||
@@ -193,6 +395,9 @@ class ContactManager:
|
|||||||
对应的头像,如果不存在这返回""
|
对应的头像,如果不存在这返回""
|
||||||
"""
|
"""
|
||||||
self._head_images.update({wxid: head_image})
|
self._head_images.update({wxid: head_image})
|
||||||
|
# 群成员头像变化通常意味着微信端已经给了新的资源地址,
|
||||||
|
# 这里即时重拉一次本地缓存,保证通讯录和后续渲染尽快拿到最新头像。
|
||||||
|
self.ensure_head_image_cached(wxid)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_group_name(self, roomid: str, wxid: str) -> str:
|
def get_group_name(self, roomid: str, wxid: str) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user