优化社交图展示并为通讯录接入本地头像缓存

This commit is contained in:
liuwei
2026-04-27 09:13:01 +08:00
parent 0636e0453f
commit e573fd9c37
4 changed files with 274 additions and 37 deletions

View File

@@ -4,7 +4,8 @@ import re
import threading
import xml.etree.ElementTree as ET
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 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('/')
@login_required
@@ -375,7 +397,9 @@ def api_head_images():
"""获取联系人头像信息API"""
try:
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({
"success": True,
@@ -388,6 +412,27 @@ def api_head_images():
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'])
@login_required
def api_group_members(roomid):

View File

@@ -669,7 +669,7 @@
<el-dialog title="公共好友详情" :visible.sync="publicDetailDialogVisible" width="50%">
<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"/>
</el-avatar>
</div>
@@ -968,6 +968,10 @@
},
refreshContacts() { this.loadContactsData(); this.$message.success('联系人数据已刷新'); },
handleTabClick() { this.currentPage = 1; },
// 通讯录头像统一走后台代理接口:
// 1. 优先命中服务端已缓存的本地头像;
// 2. 头像更新后会附带版本参数,浏览器不会一直吃旧图;
// 3. 代理接口兜底远端地址,因此这里保持简单读取即可。
getHeadImage(wxid) { return this.headImages[wxid] || ''; },
handleSizeChange(size) { this.pageSize = size; },
handleCurrentChange(page) { this.currentPage = page; },

View File

@@ -1394,8 +1394,8 @@ class ValueRankPlugin(MessagePluginInterface):
cm = ContactManager.get_instance()
edge_svg_parts: List[str] = []
# 双向@标签层:
# 1. 每条边中点标注“a->b / b->a”
# 2. 使用白底半透明标签提升可读性,避免与连线重叠难辨识
# 1. 每条边只保留双向次数数字,避免标签信息量过大把图面压得太满
# 2. 仍保留白底半透明标签,确保在密集连线里也能看清数值
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):
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}" />'
)
# 通过字典序固定方向,确保同一条边每次渲染文案方向一致。
# 标签文案改为“昵称 + 双向次数 + 恶搞关系标签”,不再展示 wxid
# 为避免标签压在线条上,这里把标签放到“线段法线方向”的偏移位置。
# 当前标签只保留双向互动数字,同时继续沿法线方向偏移,避免数字压在线条上
mx, my = (ax + bx) / 2.0, (ay + by) / 2.0
dx, dy = (bx - ax), (by - ay)
edge_len = max(math.hypot(dx, dy), 1.0)
@@ -1426,25 +1425,9 @@ class ValueRankPlugin(MessagePluginInterface):
angle_deg = math.degrees(math.atan2(dy, dx))
if angle_deg > 90 or angle_deg < -90:
angle_deg += 180
nick_a = cm.get_group_name(group_id, a) or a
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}"
label_text = f"{int(a_to_b_count)}/{int(b_to_a_count)}"
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(
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" '
@@ -1503,17 +1486,17 @@ class ValueRankPlugin(MessagePluginInterface):
f'{html.escape(str(nick)[:1] or "?")}</text>'
)
# 节点文案放到节点外侧
# 1. 外圈节点文本沿“从中心指向外部”的方向偏移,减少互相压住
# 2. 内圈节点仍保持较近距离,保证中心区域不会炸开
# 3. 文本锚点根据左右半区自动切换,让阅读方向更自然
# 节点文案继续放在头像外侧,但整体收紧一圈
# 1. 用户反馈昵称和头像分得太开,这里缩短外偏移距离
# 2. 昵称字号调小,减少节点一多时的横向挤压
# 3. 统计行与昵称保持较近距离,让“头像-昵称-数据”看起来是一组
outward_dx = math.cos(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_y = y + outward_dy * label_gap
info_x = x + outward_dx * (label_gap + 22.0)
info_y = y + outward_dy * (label_gap + 22.0)
info_x = x + outward_dx * (label_gap + 12.0)
info_y = y + outward_dy * (label_gap + 12.0) + 1.5
if abs(outward_dx) < 0.20:
anchor = "middle"
elif outward_dx > 0:
@@ -1522,11 +1505,11 @@ class ValueRankPlugin(MessagePluginInterface):
anchor = "end"
node_svg_parts.append(
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(
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)

View File

@@ -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: